From 0258f1e91ad3795ad3ed61930000b9dec8bd5638 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 11 Feb 2026 18:00:19 +0100 Subject: [PATCH 1/3] fix: preserve index field in updateBatch() when updateOnlyChanged is true --- system/BaseModel.php | 24 ++++++- tests/system/Models/UpdateModelTest.php | 77 +++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/system/BaseModel.php b/system/BaseModel.php index 06af2ded411f..ee3d1a859955 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -1099,6 +1099,21 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc { if (is_array($set)) { foreach ($set as &$row) { + // Save the index value from the original row because + // transformDataToArray() may strip it when updateOnlyChanged + // is true. + $updateIndex = null; + + if ($this->updateOnlyChanged) { + if (is_array($row)) { + $updateIndex = $row[$index] ?? null; + } elseif ($row instanceof Entity) { + $updateIndex = $row->toRawArray()[$index] ?? null; + } elseif (is_object($row)) { + $updateIndex = $row->{$index} ?? null; + } + } + $row = $this->transformDataToArray($row, 'update'); // Validate data before saving. @@ -1106,8 +1121,13 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc return false; } - // Save updateIndex for later - $updateIndex = $row[$index] ?? null; + // When updateOnlyChanged is true, restore the pre-extracted + // index into $row. Otherwise read it from the transformed row. + if ($updateIndex !== null) { + $row[$index] = $updateIndex; + } else { + $updateIndex = $row[$index] ?? null; + } if ($updateIndex === null) { throw new InvalidArgumentException( diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 94f8df12ea73..07de27fe133c 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -265,6 +265,83 @@ public function testUpdateBatchWithEntity(): void $this->assertSame(2, $model->updateBatch([$entity1, $entity2], 'id')); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/9943 + */ + public function testUpdateBatchWithEntityAndUpdateOnlyChanged(): void + { + $entity1 = new class () extends Entity { + protected $id; + protected $name; + protected $email; + protected $country; + protected $deleted; + protected $created_at; + protected $updated_at; + protected $_options = [ + 'datamap' => [], + 'dates' => [ + 'created_at', + 'updated_at', + 'deleted_at', + ], + 'casts' => [], + ]; + }; + + $entity2 = new class () extends Entity { + protected $id; + protected $name; + protected $email; + protected $country; + protected $deleted; + protected $created_at; + protected $updated_at; + protected $_options = [ + 'datamap' => [], + 'dates' => [ + 'created_at', + 'updated_at', + 'deleted_at', + ], + 'casts' => [], + ]; + }; + + // Set up entity1 and mark as synced, then change only country. + $entity1->id = 1; + $entity1->name = 'Derek Jones'; + $entity1->email = 'derek@world.com'; + $entity1->country = 'US'; + $entity1->deleted = 0; + $entity1->syncOriginal(); + $entity1->country = 'Greece'; + + // Set up entity2 and mark as synced, then change only country. + $entity2->id = 4; + $entity2->name = 'Chris Martin'; + $entity2->email = 'chris@world.com'; + $entity2->country = 'UK'; + $entity2->deleted = 0; + $entity2->syncOriginal(); + $entity2->country = 'Finland'; + + // updateOnlyChanged is true by default. The index field 'id' is + // unchanged but must be preserved for the batch WHERE clause. + $model = $this->createModel(UserModel::class); + $this->assertTrue($this->getPrivateProperty($model, 'updateOnlyChanged')); + $this->assertSame(2, $model->updateBatch([$entity1, $entity2], 'id')); + + $this->seeInDatabase('user', [ + 'name' => 'Derek Jones', + 'country' => 'Greece', + ]); + $this->seeInDatabase('user', [ + 'name' => 'Chris Martin', + 'country' => 'Finland', + ]); + } + public function testUpdateNoPrimaryKey(): void { $this->db->table('secondary')->insert([ diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 8a94a731a413..a6152e840301 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -33,6 +33,7 @@ Bugs Fixed ********** - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. +- **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. See the repo's From b150d170a49bc84290ceabe710055914cbe84b40 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 11 Feb 2026 18:10:41 +0100 Subject: [PATCH 2/3] phpstan baseline --- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.property.neon | 98 +++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 7c302e27351d..87e22ee4b906 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2122 errors +# total 2138 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index fdf4057fee76..2d56217b42e3 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 102 errors +# total 118 errors parameters: ignoreErrors: @@ -468,42 +468,122 @@ parameters: path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$updated_at has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$_options has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$country has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$created_at has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$deleted has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$email has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$id has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$name has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$updated_at has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$_options has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$country has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$created_at has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$deleted has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$email has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$id has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$name has no type specified\.$#' + count: 1 + path: ../../tests/system/Models/UpdateModelTest.php + + - + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php From ecee0659352a028cc89985726ec1a18a4d4fa791 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 11 Feb 2026 18:34:23 +0100 Subject: [PATCH 3/3] update test --- tests/system/Models/UpdateModelTest.php | 44 +-------- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.property.neon | 98 ++----------------- 3 files changed, 12 insertions(+), 132 deletions(-) diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 07de27fe133c..2495834ba978 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -270,59 +270,19 @@ public function testUpdateBatchWithEntity(): void */ public function testUpdateBatchWithEntityAndUpdateOnlyChanged(): void { - $entity1 = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], - ]; - }; - - $entity2 = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], - ]; - }; - - // Set up entity1 and mark as synced, then change only country. + $entity1 = new User(); $entity1->id = 1; $entity1->name = 'Derek Jones'; $entity1->email = 'derek@world.com'; $entity1->country = 'US'; - $entity1->deleted = 0; $entity1->syncOriginal(); $entity1->country = 'Greece'; - // Set up entity2 and mark as synced, then change only country. + $entity2 = new User(); $entity2->id = 4; $entity2->name = 'Chris Martin'; $entity2->email = 'chris@world.com'; $entity2->country = 'UK'; - $entity2->deleted = 0; $entity2->syncOriginal(); $entity2->country = 'Finland'; diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 87e22ee4b906..7c302e27351d 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2138 errors +# total 2122 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index 2d56217b42e3..2083602a9d54 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 118 errors +# total 102 errors parameters: ignoreErrors: @@ -468,122 +468,42 @@ parameters: path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:273\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:292\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:441\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php