Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG-5.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ 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

## [5.17.0](https://github.com/phalcon/cphalcon/releases/tag/v5.17.0) (2026-07-17)
Expand Down
24 changes: 12 additions & 12 deletions ext/phalcon/mvc/model.zep.c

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions phalcon/Mvc/Model.zep
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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];

Expand All @@ -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];

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;

/**
Expand Down
196 changes: 196 additions & 0 deletions tests/database/Mvc/Model/InsertDefaultValuesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

/**
* This file is part of the Phalcon Framework.
*
* (c) Phalcon Team <team@phalcon.io>
*
* 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 <team@phalcon.io>
* @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 <team@phalcon.io>
* @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 <team@phalcon.io>
* @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;
}
}
Loading