From e09263d6a01ba0a1bf44eb9a8ddf1f9dac72cf13 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Mon, 20 Jul 2026 12:44:43 -0500 Subject: [PATCH 1/5] [#17382] - fixing skipAttributes for null fields Assited-by: Claude Code --- phalcon/Mvc/Model.zep | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/phalcon/Mvc/Model.zep b/phalcon/Mvc/Model.zep index f4c27a846b..a343250a9e 100644 --- a/phalcon/Mvc/Model.zep +++ b/phalcon/Mvc/Model.zep @@ -4111,7 +4111,7 @@ abstract class Model extends AbstractInjectionAware implements EntityInterface, let attributeField = field; } - if !isset automaticAttributes[attributeField] { + if !array_key_exists(attributeField, automaticAttributes) { /** * Check every attribute in the model except identity field */ @@ -4398,7 +4398,7 @@ abstract class Model extends AbstractInjectionAware implements EntityInterface, } else { let attributeField = field; } - if !isset automaticAttributes[attributeField] { + if !array_key_exists(attributeField, automaticAttributes) { /** * Check a bind type for field to update */ @@ -4521,7 +4521,7 @@ abstract class Model extends AbstractInjectionAware implements EntityInterface, let attributeField = field; } - if !isset automaticAttributes[attributeField] { + if !array_key_exists(attributeField, automaticAttributes) { /** * Check a bind type for field to update */ @@ -5235,7 +5235,7 @@ abstract class Model extends AbstractInjectionAware implements EntityInterface, /** * We don't check fields that must be omitted */ - if !isset automaticAttributes[attributeField] { + if !array_key_exists(attributeField, automaticAttributes) { let isNull = false; /** From f04770a53f070adbf277c1b91fcd52e1c87110f2 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Mon, 20 Jul 2026 12:44:50 -0500 Subject: [PATCH 2/5] [#17382] - refreshing ext --- ext/phalcon/mvc/model.zep.c | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ext/phalcon/mvc/model.zep.c b/ext/phalcon/mvc/model.zep.c index ef8c7e6039..113fd6c3d5 100644 --- a/ext/phalcon/mvc/model.zep.c +++ b/ext/phalcon/mvc/model.zep.c @@ -8031,7 +8031,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowInsert) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { if (!ZEPHIR_IS_EQUAL(&field, identityField)) { ZEPHIR_OBS_NVAR(&rawValue); if (zephir_array_isset_fetch(&rawValue, &rawValues, &attributeField, 0)) { @@ -8147,7 +8147,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowInsert) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { if (!ZEPHIR_IS_EQUAL(&field, identityField)) { ZEPHIR_OBS_NVAR(&rawValue); if (zephir_array_isset_fetch(&rawValue, &rawValues, &attributeField, 0)) { @@ -8617,7 +8617,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowUpdate) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { ZEPHIR_OBS_NVAR(&bindType); if (UNEXPECTED(!(zephir_array_isset_fetch(&bindType, &bindDataTypes, &field, 0)))) { ZEPHIR_INIT_NVAR(&_11$$12); @@ -8757,7 +8757,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowUpdate) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { ZEPHIR_OBS_NVAR(&bindType); if (UNEXPECTED(!(zephir_array_isset_fetch(&bindType, &bindDataTypes, &field, 0)))) { ZEPHIR_INIT_NVAR(&_31$$36); @@ -8891,7 +8891,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowUpdate) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { ZEPHIR_OBS_NVAR(&bindType); if (UNEXPECTED(!(zephir_array_isset_fetch(&bindType, &bindDataTypes, &field, 0)))) { ZEPHIR_INIT_NVAR(&_47$$62); @@ -8983,7 +8983,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowUpdate) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { ZEPHIR_OBS_NVAR(&bindType); if (UNEXPECTED(!(zephir_array_isset_fetch(&bindType, &bindDataTypes, &field, 0)))) { ZEPHIR_INIT_NVAR(&_59$$73); @@ -10135,7 +10135,7 @@ PHP_METHOD(Phalcon_Mvc_Model, preSave) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { isNull = 0; ZEPHIR_OBS_NVAR(&value); if (zephir_fetch_property_zval(&value, this_ptr, &attributeField, PH_SILENT_CC)) { @@ -10240,7 +10240,7 @@ PHP_METHOD(Phalcon_Mvc_Model, preSave) } else { ZEPHIR_CPY_WRT(&attributeField, &field); } - if (!(zephir_array_isset_value(&automaticAttributes, &attributeField))) { + if (!(zephir_array_key_exists(&automaticAttributes, &attributeField))) { isNull = 0; ZEPHIR_OBS_NVAR(&value); if (zephir_fetch_property_zval(&value, this_ptr, &attributeField, PH_SILENT_CC)) { From 06eab99ce22c4774d8bbec25b672e92d6bd49718 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Mon, 20 Jul 2026 12:45:01 -0500 Subject: [PATCH 3/5] [#17382] - adding tests Assisted-by: Claude Code --- .../database/Mvc/Model/SkipAttributesTest.php | 225 ++++++++++++++++++ tests/support/Models/InvoicesSkipDynamic.php | 36 +++ 2 files changed, 261 insertions(+) create mode 100644 tests/database/Mvc/Model/SkipAttributesTest.php create mode 100644 tests/support/Models/InvoicesSkipDynamic.php diff --git a/tests/database/Mvc/Model/SkipAttributesTest.php b/tests/database/Mvc/Model/SkipAttributesTest.php new file mode 100644 index 0000000000..316401c61c --- /dev/null +++ b/tests/database/Mvc/Model/SkipAttributesTest.php @@ -0,0 +1,225 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Database\Mvc\Model; + +use PDO; +use Phalcon\Events\Event; +use Phalcon\Events\Manager; +use Phalcon\Tests\AbstractDatabaseTestCase; +use Phalcon\Tests\Support\Migrations\InvoicesMigration; +use Phalcon\Tests\Support\Models\InvoicesSkipCreate; +use Phalcon\Tests\Support\Models\InvoicesSkipDynamic; +use Phalcon\Tests\Support\Traits\DiTrait; +use PHPUnit\Framework\Attributes\Group; + +use function date; +use function str_starts_with; +use function uniqid; + +final class SkipAttributesTest extends AbstractDatabaseTestCase +{ + use DiTrait; + + private array $statements = []; + + public function setUp(): void + { + $this->setNewFactoryDefault(); + $this->setDatabase(); + + /** @var PDO $connection */ + $connection = self::getPdoConnection(); + (new InvoicesMigration($connection)); + + $this->statements = []; + } + + public function tearDown(): void + { + $this->tearDownDatabase(); + } + + /** + * Tests that an attribute registered with skipAttributes() is not part of + * the generated INSERT statement. + * + * @issue https://github.com/phalcon/cphalcon/issues/17382 + * + * @author Phalcon Team + * @since 2026-07-20 + */ + #[Group('mysql')] + #[Group('pgsql')] + #[Group('sqlite')] + public function testMvcModelSkipAttributesOmitsAttributeFromInsert(): void + { + $connection = $this->recordStatements(); + + $invoice = new InvoicesSkipCreate(); + $invoice->inv_cst_id = 2; + $invoice->inv_status_flag = 1; + $invoice->inv_title = uniqid('inv-'); + $invoice->inv_total = 100.12; + $invoice->inv_created_at = date('Y-m-d H:i:s'); + + $this->assertTrue($invoice->create()); + + /** + * The skipped attribute must not be part of the INSERT + */ + $actual = $this->getStatement('INSERT'); + $this->assertStringNotContainsString('inv_created_at', $actual); + + /** + * The value assigned to the model must never reach the database + */ + $actual = $connection->fetchOne( + 'select inv_created_at from co_invoices where inv_id = ' . $invoice->inv_id + ); + $this->assertNull($actual['inv_created_at']); + } + + /** + * Tests that an attribute registered with skipAttributes() is not part of + * the generated UPDATE statement when dynamic update is enabled. + * + * @issue https://github.com/phalcon/cphalcon/issues/17382 + * + * @author Phalcon Team + * @since 2026-07-20 + */ + #[Group('mysql')] + #[Group('pgsql')] + #[Group('sqlite')] + public function testMvcModelSkipAttributesOmitsAttributeFromDynamicUpdate(): void + { + $date = '2026-07-20 10:00:00'; + + /** @var PDO $connection */ + $connection = self::getPdoConnection(); + (new InvoicesMigration($connection))->insert(4, 1, 1, 'inv-title', 100.12, $date); + + $connection = $this->recordStatements(); + + /** @var InvoicesSkipDynamic $invoice */ + $invoice = InvoicesSkipDynamic::findFirst( + [ + 'conditions' => 'inv_id = :id:', + 'bind' => ['id' => 4], + ] + ); + $invoice->inv_title = uniqid('inv-'); + $invoice->inv_created_at = '2026-07-20 11:00:00'; + + $this->assertTrue($invoice->save()); + + /** + * The skipped attribute must not be part of the UPDATE + */ + $actual = $this->getStatement('UPDATE'); + $this->assertStringNotContainsString('inv_created_at', $actual); + + /** + * The value assigned to the model must never reach the database + */ + $actual = $connection->fetchOne( + 'select inv_created_at from co_invoices where inv_id = 4' + ); + $this->assertStringStartsWith('2026-07-20 10:00:00', $actual['inv_created_at']); + } + + /** + * Tests that an attribute registered with skipAttributes() is not part of + * the generated UPDATE statement. + * + * @issue https://github.com/phalcon/cphalcon/issues/17382 + * + * @author Phalcon Team + * @since 2026-07-20 + */ + #[Group('mysql')] + #[Group('pgsql')] + #[Group('sqlite')] + public function testMvcModelSkipAttributesOmitsAttributeFromUpdate(): void + { + $date = '2026-07-20 10:00:00'; + + /** @var PDO $connection */ + $connection = self::getPdoConnection(); + (new InvoicesMigration($connection))->insert(3, 1, 1, 'inv-title', 100.12, $date); + + $connection = $this->recordStatements(); + + /** @var InvoicesSkipCreate $invoice */ + $invoice = InvoicesSkipCreate::findFirst( + [ + 'conditions' => 'inv_id = :id:', + 'bind' => ['id' => 3], + ] + ); + $invoice->inv_title = uniqid('inv-'); + $invoice->inv_created_at = '2026-07-20 11:00:00'; + + $this->assertTrue($invoice->save()); + + /** + * The skipped attribute must not be part of the UPDATE + */ + $actual = $this->getStatement('UPDATE'); + $this->assertStringNotContainsString('inv_created_at', $actual); + + /** + * The value assigned to the model must never reach the database + */ + $actual = $connection->fetchOne( + 'select inv_created_at from co_invoices where inv_id = 3' + ); + $this->assertStringStartsWith('2026-07-20 10:00:00', $actual['inv_created_at']); + } + + /** + * Returns the first recorded statement starting with the given prefix + */ + private function getStatement(string $prefix): string + { + foreach ($this->statements as $statement) { + if (str_starts_with($statement, $prefix)) { + return $statement; + } + } + + return ''; + } + + /** + * Attaches a listener to the connection recording every statement sent + * to the database + */ + private function recordStatements() + { + $connection = $this->container->get('db'); + $manager = new Manager(); + + $manager->attach( + 'db:beforeQuery', + function (Event $event) use ($connection) { + $this->statements[] = $connection->getSQLStatement(); + } + ); + + $connection->setEventsManager($manager); + + return $connection; + } +} diff --git a/tests/support/Models/InvoicesSkipDynamic.php b/tests/support/Models/InvoicesSkipDynamic.php new file mode 100644 index 0000000000..9575da1e03 --- /dev/null +++ b/tests/support/Models/InvoicesSkipDynamic.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\Models; + +use Phalcon\Mvc\Model; + +class InvoicesSkipDynamic extends Model +{ + public $inv_id; + public $inv_cst_id; + public $inv_status_flag; + public $inv_title; + public $inv_total; + public $inv_created_at; + + public function initialize() + { + $this->setSource('co_invoices'); + + $this->keepSnapshots(true); + $this->useDynamicUpdate(true); + + $this->skipAttributes(['inv_created_at']); + } +} From df99a813656b662240f5a858f3cdd847b5d6d5a5 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Mon, 20 Jul 2026 12:45:08 -0500 Subject: [PATCH 4/5] [#17382] - updating changelog --- CHANGELOG-5.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG-5.0.md b/CHANGELOG-5.0.md index 26397ae2ff..59363def7e 100644 --- a/CHANGELOG-5.0.md +++ b/CHANGELOG-5.0.md @@ -29,6 +29,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo ### Fixed +- Fixed `Phalcon\Mvc\Model` ignoring attributes registered with `skipAttributes()`, `skipAttributesOnCreate()` and `skipAttributesOnUpdate()`, so a skipped column was emitted in the generated `INSERT`/`UPDATE` (breaking, for instance, inserts into a table with a MySQL generated column). The skip list is keyed with `null` values, and `isset()` on an array offset now follows PHP semantics (key present *and* value not `null`), which made every skipped attribute read as not registered; the checks in `doLowInsert()`, `doLowUpdate()` and the not-null validation now use `array_key_exists()`. [#17382](https://github.com/phalcon/cphalcon/issues/17382) [[doc]](https://docs.phalcon.io/5.18/db-models/) + ### Removed ## [5.17.0](https://github.com/phalcon/cphalcon/releases/tag/v5.17.0) (2026-07-17) From a68bc542c882028a4f4ed59573ceb472e008c41d Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Mon, 20 Jul 2026 14:50:44 -0500 Subject: [PATCH 5/5] [#17382] - additional fix for inserting nulls Assisted-by: Claude Code --- CHANGELOG-5.0.md | 1 + ext/phalcon/mvc/model.zep.c | 8 +- phalcon/Mvc/Model.zep | 4 +- .../Mvc/Model/InsertDefaultValuesTest.php | 196 ++++++++++++++++++ .../Migrations/GeneratedColumnsMigration.php | 57 +++++ tests/support/Models/GeneratedColumns.php | 28 +++ 6 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 tests/database/Mvc/Model/InsertDefaultValuesTest.php create mode 100644 tests/support/Migrations/GeneratedColumnsMigration.php create mode 100644 tests/support/Models/GeneratedColumns.php diff --git a/CHANGELOG-5.0.md b/CHANGELOG-5.0.md index 59363def7e..b4814260a5 100644 --- a/CHANGELOG-5.0.md +++ b/CHANGELOG-5.0.md @@ -30,6 +30,7 @@ All notable changes are documented here. The format is based on [Keep a Changelo ### Fixed - Fixed `Phalcon\Mvc\Model` ignoring attributes registered with `skipAttributes()`, `skipAttributesOnCreate()` and `skipAttributesOnUpdate()`, so a skipped column was emitted in the generated `INSERT`/`UPDATE` (breaking, for instance, inserts into a table with a MySQL generated column). The skip list is keyed with `null` values, and `isset()` on an array offset now follows PHP semantics (key present *and* value not `null`), which made every skipped attribute read as not registered; the checks in `doLowInsert()`, `doLowUpdate()` and the not-null validation now use `array_key_exists()`. [#17382](https://github.com/phalcon/cphalcon/issues/17382) [[doc]](https://docs.phalcon.io/5.18/db-models/) +- Fixed `Phalcon\Mvc\Model` inserting a literal `null` for a column the database can supply a value for, instead of the `DEFAULT` keyword (or omitting the column on an adapter without `DEFAULT` support, such as SQLite). This restores inserts against a MySQL `GENERATED ALWAYS AS (...) STORED` column, which rejects an explicit `null` with `SQLSTATE[HY000]: General error: 3105` but accepts `DEFAULT`. [#17382](https://github.com/phalcon/cphalcon/issues/17382) [[doc]](https://docs.phalcon.io/5.18/db-models/) ### Removed diff --git a/ext/phalcon/mvc/model.zep.c b/ext/phalcon/mvc/model.zep.c index 113fd6c3d5..4b57f29bee 100644 --- a/ext/phalcon/mvc/model.zep.c +++ b/ext/phalcon/mvc/model.zep.c @@ -8056,7 +8056,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowInsert) if (zephir_fetch_property_zval(&value, this_ptr, &attributeField, PH_SILENT_CC)) { _10$$13 = Z_TYPE_P(&value) == IS_NULL; if (_10$$13) { - _10$$13 = zephir_array_isset_value(&defaultValues, &field); + _10$$13 = zephir_array_key_exists(&defaultValues, &field); } if (_10$$13) { zephir_array_fetch(&_11$$14, &defaultValues, &field, PH_NOISY | PH_READONLY, "phalcon/Mvc/Model.zep", 4134); @@ -8089,7 +8089,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowInsert) zephir_array_append(&values, &value, PH_SEPARATE, "phalcon/Mvc/Model.zep", 4154); zephir_array_append(&bindTypes, &bindType, PH_SEPARATE, "phalcon/Mvc/Model.zep", 4155); } else { - if (zephir_array_isset_value(&defaultValues, &field)) { + if (zephir_array_key_exists(&defaultValues, &field)) { zephir_array_fetch(&_18$$19, &defaultValues, &field, PH_NOISY | PH_READONLY, "phalcon/Mvc/Model.zep", 4158); zephir_array_update_zval(&snapshot, &attributeField, &_18$$19, PH_COPY | PH_SEPARATE); zephir_array_fetch(&_19$$19, &defaultValues, &field, PH_NOISY | PH_READONLY, "phalcon/Mvc/Model.zep", 4159); @@ -8172,7 +8172,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowInsert) if (zephir_fetch_property_zval(&value, this_ptr, &attributeField, PH_SILENT_CC)) { _30$$30 = Z_TYPE_P(&value) == IS_NULL; if (_30$$30) { - _30$$30 = zephir_array_isset_value(&defaultValues, &field); + _30$$30 = zephir_array_key_exists(&defaultValues, &field); } if (_30$$30) { zephir_array_fetch(&_31$$31, &defaultValues, &field, PH_NOISY | PH_READONLY, "phalcon/Mvc/Model.zep", 4134); @@ -8205,7 +8205,7 @@ PHP_METHOD(Phalcon_Mvc_Model, doLowInsert) zephir_array_append(&values, &value, PH_SEPARATE, "phalcon/Mvc/Model.zep", 4154); zephir_array_append(&bindTypes, &bindType, PH_SEPARATE, "phalcon/Mvc/Model.zep", 4155); } else { - if (zephir_array_isset_value(&defaultValues, &field)) { + if (zephir_array_key_exists(&defaultValues, &field)) { zephir_array_fetch(&_38$$36, &defaultValues, &field, PH_NOISY | PH_READONLY, "phalcon/Mvc/Model.zep", 4158); zephir_array_update_zval(&snapshot, &attributeField, &_38$$36, PH_COPY | PH_SEPARATE); zephir_array_fetch(&_39$$36, &defaultValues, &field, PH_NOISY | PH_READONLY, "phalcon/Mvc/Model.zep", 4159); diff --git a/phalcon/Mvc/Model.zep b/phalcon/Mvc/Model.zep index a343250a9e..6a211ef9fa 100644 --- a/phalcon/Mvc/Model.zep +++ b/phalcon/Mvc/Model.zep @@ -4130,7 +4130,7 @@ abstract class Model extends AbstractInjectionAware implements EntityInterface, bindTypes[] = bindType, snapshot[attributeField] = rawValue; } elseif fetch value, this->{attributeField} { - if value === null && isset defaultValues[field] { + if value === null && array_key_exists(field, defaultValues) { let snapshot[attributeField] = defaultValues[field], unsetDefaultValues[attributeField] = defaultValues[field]; @@ -4154,7 +4154,7 @@ abstract class Model extends AbstractInjectionAware implements EntityInterface, values[] = value, bindTypes[] = bindType; } else { - if isset defaultValues[field] { + if array_key_exists(field, defaultValues) { let snapshot[attributeField] = defaultValues[field], unsetDefaultValues[attributeField] = defaultValues[field]; diff --git a/tests/database/Mvc/Model/InsertDefaultValuesTest.php b/tests/database/Mvc/Model/InsertDefaultValuesTest.php new file mode 100644 index 0000000000..fe644c6de4 --- /dev/null +++ b/tests/database/Mvc/Model/InsertDefaultValuesTest.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Database\Mvc\Model; + +use PDO; +use Phalcon\Events\Event; +use Phalcon\Events\Manager; +use Phalcon\Tests\AbstractDatabaseTestCase; +use Phalcon\Tests\Support\Migrations\GeneratedColumnsMigration; +use Phalcon\Tests\Support\Migrations\InvoicesMigration; +use Phalcon\Tests\Support\Models\GeneratedColumns; +use Phalcon\Tests\Support\Models\Invoices; +use Phalcon\Tests\Support\Traits\DiTrait; +use PHPUnit\Framework\Attributes\Group; + +use function md5; +use function str_starts_with; +use function strtoupper; +use function uniqid; + +final class InsertDefaultValuesTest extends AbstractDatabaseTestCase +{ + use DiTrait; + + private ?GeneratedColumnsMigration $migration = null; + + private array $statements = []; + + public function setUp(): void + { + $this->setNewFactoryDefault(); + $this->setDatabase(); + + /** @var PDO $connection */ + $connection = self::getPdoConnection(); + (new InvoicesMigration($connection)); + + $this->statements = []; + } + + public function tearDown(): void + { + if (null !== $this->migration) { + $this->migration->drop(); + $this->migration = null; + } + + $this->tearDownDatabase(); + } + + /** + * Tests that a column the database can supply a value for is inserted with + * `DEFAULT` instead of a bound `null`, for a nullable column that carries + * no explicit default. + * + * @issue https://github.com/phalcon/cphalcon/issues/17382 + * + * @author Phalcon Team + * @since 2026-07-20 + */ + #[Group('mysql')] + #[Group('pgsql')] + public function testMvcModelInsertUsesDefaultForNullableColumn(): void + { + $this->recordStatements(); + + $invoice = new Invoices(); + $invoice->inv_cst_id = 2; + $invoice->inv_status_flag = 1; + $invoice->inv_title = uniqid('inv-'); + $invoice->inv_total = 100.12; + + $this->assertTrue($invoice->create()); + + /** + * The column stays in the statement, but as the `DEFAULT` keyword - + * never as a literal `null`. `DEFAULT` on its own is not enough of an + * assertion: PostgreSQL also emits it for the identity column. + */ + $actual = $this->getStatement('INSERT'); + $this->assertStringContainsString('inv_created_at', $actual); + $this->assertStringNotContainsString('null', $actual); + } + + /** + * Tests that a column the database can supply a value for is left out of + * the INSERT entirely on an adapter that does not support the `DEFAULT` + * keyword (SQLite), rather than being bound as `null`. + * + * @issue https://github.com/phalcon/cphalcon/issues/17382 + * + * @author Phalcon Team + * @since 2026-07-20 + */ + #[Group('sqlite')] + public function testMvcModelInsertOmitsDefaultColumnWithoutDefaultSupport(): void + { + $this->recordStatements(); + + $invoice = new Invoices(); + $invoice->inv_cst_id = 2; + $invoice->inv_status_flag = 1; + $invoice->inv_title = uniqid('inv-'); + $invoice->inv_total = 100.12; + + $this->assertTrue($invoice->create()); + + $actual = $this->getStatement('INSERT'); + $this->assertStringNotContainsString('inv_created_at', $actual); + } + + /** + * Tests that a MySQL stored generated column is inserted with `DEFAULT`, + * which MySQL computes, instead of an explicit `null`, which MySQL rejects + * with `SQLSTATE[HY000]: General error: 3105`. + * + * @issue https://github.com/phalcon/cphalcon/issues/17382 + * + * @author Phalcon Team + * @since 2026-07-20 + */ + #[Group('mysql')] + public function testMvcModelInsertUsesDefaultForGeneratedColumn(): void + { + /** @var PDO $connection */ + $pdo = self::getPdoConnection(); + $this->migration = new GeneratedColumnsMigration($pdo, false); + $this->migration->create(); + + $connection = $this->recordStatements(); + + $url = 'https://example.com/' . uniqid(); + $record = new GeneratedColumns(); + $record->gen_url = $url; + + $this->assertTrue($record->create()); + + $actual = $this->getStatement('INSERT'); + $this->assertStringContainsString('DEFAULT', $actual); + + /** + * The database computed the column + */ + $actual = $connection->fetchOne( + 'select hex(gen_url_hash) as gen_url_hash from co_generated ' + . 'where gen_id = ' . $record->gen_id + ); + $this->assertSame(strtoupper(md5($url)), $actual['gen_url_hash']); + } + + /** + * Returns the first recorded statement starting with the given prefix + */ + private function getStatement(string $prefix): string + { + foreach ($this->statements as $statement) { + if (str_starts_with($statement, $prefix)) { + return $statement; + } + } + + return ''; + } + + /** + * Attaches a listener to the connection recording every statement sent + * to the database + */ + private function recordStatements() + { + $connection = $this->container->get('db'); + $manager = new Manager(); + + $manager->attach( + 'db:beforeQuery', + function (Event $event) use ($connection) { + $this->statements[] = $connection->getSQLStatement(); + } + ); + + $connection->setEventsManager($manager); + + return $connection; + } +} diff --git a/tests/support/Migrations/GeneratedColumnsMigration.php b/tests/support/Migrations/GeneratedColumnsMigration.php new file mode 100644 index 0000000000..8f183d2830 --- /dev/null +++ b/tests/support/Migrations/GeneratedColumnsMigration.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the + * LICENSE.txt file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\Migrations; + +/** + * Class GeneratedColumnsMigration + * + * MySQL only. A stored generated column rejects any explicitly supplied value + * (error 3105) but accepts the `DEFAULT` keyword. + */ +class GeneratedColumnsMigration extends AbstractMigration +{ + protected $table = 'co_generated'; + + protected function getSqlMysql(): array + { + return [ + " +drop table if exists `co_generated`; + ", + " +create table co_generated +( + `gen_id` int(10) auto_increment primary key, + `gen_url` varchar(255) not null, + `gen_url_hash` binary(16) generated always as (unhex(md5(gen_url))) stored +); + ", + ]; + } + + protected function getSqlPgsql(): array + { + return []; + } + + protected function getSqlSqlite(): array + { + return []; + } + + protected function getSqlSqlsrv(): array + { + return []; + } +} diff --git a/tests/support/Models/GeneratedColumns.php b/tests/support/Models/GeneratedColumns.php new file mode 100644 index 0000000000..7d1c4e2730 --- /dev/null +++ b/tests/support/Models/GeneratedColumns.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\Models; + +use Phalcon\Mvc\Model; + +class GeneratedColumns extends Model +{ + public $gen_id; + public $gen_url; + public $gen_url_hash; + + public function initialize() + { + $this->setSource('co_generated'); + } +}