diff --git a/system/BaseModel.php b/system/BaseModel.php index f5b0526eff1e..baab9494ba8d 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -155,6 +155,13 @@ abstract class BaseModel */ protected $allowedFields = []; + /** + * Fields that may be inserted but not updated through Model write methods. + * + * @var list + */ + protected array $insertOnlyFields = []; + /** * If true, will set created_at, and updated_at * values during insert and update routines. @@ -1162,7 +1169,10 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc // Must be called first so we don't // strip out updated_at values. - $this->ensureNoDisallowedFields($row, $index === null ? [] : [$index]); + $ignoredFields = $index === null ? [] : [$index]; + + $row = $this->doProtectInsertOnlyFieldsForUpdate($row, $ignoredFields); + $this->ensureNoDisallowedFields($row, $ignoredFields); $row = $this->doProtectFields($row); // Restore updateIndex value in case it was wiped out @@ -1375,6 +1385,20 @@ public function setAllowedFields(array $allowedFields) return $this; } + /** + * Sets the fields that may be inserted but not updated. + * + * @param list $insertOnlyFields + * + * @return $this + */ + public function setInsertOnlyFields(array $insertOnlyFields) + { + $this->insertOnlyFields = $insertOnlyFields; + + return $this; + } + /** * Sets whether or not we should whitelist data set during * updates or inserts against $this->availableFields. @@ -1462,6 +1486,42 @@ protected function ensureNoDisallowedFields(array $row, array $ignoredFields = [ } } + /** + * Removes fields from update data when they may only be inserted. + * + * @param row_array $row + * @param list $ignoredFields + * + * @return row_array + * + * @throws DataException + */ + protected function doProtectInsertOnlyFieldsForUpdate(array $row, array $ignoredFields = []): array + { + if (! $this->protectFields || $this->allowedFields === [] || $this->insertOnlyFields === []) { + return $row; + } + + $insertOnlyFields = []; + + foreach (array_keys($row) as $key) { + if (in_array($key, $ignoredFields, true)) { + continue; + } + + if (in_array($key, $this->insertOnlyFields, true)) { + $insertOnlyFields[] = $key; + unset($row[$key]); + } + } + + if ($insertOnlyFields !== [] && $this->throwOnDisallowedFields) { + throw DataException::forInsertOnlyFields(static::class, $insertOnlyFields); + } + + return $row; + } + /** * Ensures that only the fields that are allowed to be inserted are in * the data array. @@ -1496,6 +1556,7 @@ protected function doProtectFieldsForInsert(array $row): array */ protected function doProtectFieldsForUpdate(array $row): array { + $row = $this->doProtectInsertOnlyFieldsForUpdate($row); $this->ensureNoDisallowedFields($row); return $this->doProtectFields($row); diff --git a/system/Commands/Generators/Views/model.tpl.php b/system/Commands/Generators/Views/model.tpl.php index 455ce7a53600..c8b76b873ebc 100644 --- a/system/Commands/Generators/Views/model.tpl.php +++ b/system/Commands/Generators/Views/model.tpl.php @@ -7,15 +7,16 @@ class {class} extends Model { - protected $DBGroup = '{dbGroup}'; + protected $DBGroup = '{dbGroup}'; - protected $table = '{table}'; - protected $primaryKey = 'id'; - protected $useAutoIncrement = true; - protected $returnType = {return}; - protected $useSoftDeletes = false; - protected $protectFields = true; - protected $allowedFields = []; + protected $table = '{table}'; + protected $primaryKey = 'id'; + protected $useAutoIncrement = true; + protected $returnType = {return}; + protected $useSoftDeletes = false; + protected $protectFields = true; + protected $allowedFields = []; + protected array $insertOnlyFields = []; protected bool $throwOnDisallowedFields = false; protected bool $allowEmptyInserts = false; diff --git a/system/Database/Exceptions/DataException.php b/system/Database/Exceptions/DataException.php index c61f4debe81f..7f6c3c25a517 100644 --- a/system/Database/Exceptions/DataException.php +++ b/system/Database/Exceptions/DataException.php @@ -83,6 +83,16 @@ public static function forDisallowedFields(string $model, array $fields) return new static(lang('Database.disallowedFields', [$model, implode(', ', $fields)])); } + /** + * @param list $fields + * + * @return DataException + */ + public static function forInsertOnlyFields(string $model, array $fields) + { + return new static(lang('Database.insertOnlyFields', [$model, implode(', ', $fields)])); + } + /** * @return DataException */ diff --git a/system/Language/en/Database.php b/system/Language/en/Database.php index 94c8d3a9a7fc..084d2050f549 100644 --- a/system/Language/en/Database.php +++ b/system/Language/en/Database.php @@ -17,6 +17,7 @@ 'invalidArgument' => 'You must provide a valid "{0}".', 'invalidAllowedFields' => 'Allowed fields must be specified for model: "{0}"', 'disallowedFields' => 'Fields are not allowed for model "{0}": {1}', + 'insertOnlyFields' => 'Fields cannot be updated for model "{0}": {1}', 'emptyDataset' => 'There is no data to {0}.', 'emptyPrimaryKey' => 'There is no primary key defined when trying to make {0}.', 'failGetFieldData' => 'Failed to get field data from database.', diff --git a/system/Model.php b/system/Model.php index b107184b23f7..f642e1eca8a1 100644 --- a/system/Model.php +++ b/system/Model.php @@ -785,6 +785,7 @@ protected function doProtectFieldsForInsert(array $row): array protected function doProtectFieldsForUpdate(array $row): array { + $row = $this->doProtectInsertOnlyFieldsForUpdate($row, [$this->primaryKey]); $this->ensureNoDisallowedFields($row, [$this->primaryKey]); return $this->doProtectFields($row); diff --git a/tests/system/Commands/Generators/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php index 520bbbdc7a3b..d8cf6f8298af 100644 --- a/tests/system/Commands/Generators/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -53,8 +53,8 @@ public function testGenerateModel(): void $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); $this->assertStringContainsString('extends Model', $this->getFileContent($file)); - $this->assertStringContainsString('protected $table = \'users\';', $this->getFileContent($file)); - $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $table = \'users\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionTable(): void @@ -63,7 +63,7 @@ public function testGenerateModelWithOptionTable(): void $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $file = APPPATH . 'Models/Cars.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $table = \'utilisateur\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $table = \'utilisateur\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionDBGroup(): void @@ -72,7 +72,7 @@ public function testGenerateModelWithOptionDBGroup(): void $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $DBGroup = \'testing\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $DBGroup = \'testing\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionReturnArray(): void @@ -81,7 +81,7 @@ public function testGenerateModelWithOptionReturnArray(): void $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionReturnObject(): void @@ -90,7 +90,7 @@ public function testGenerateModelWithOptionReturnObject(): void $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'object\';', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \'object\';', $this->getFileContent($file)); } public function testGenerateModelWithOptionReturnEntity(): void @@ -100,7 +100,7 @@ public function testGenerateModelWithOptionReturnEntity(): void $file = APPPATH . 'Models/User.php'; $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \App\Entities\User::class;', $this->getFileContent($file)); + $this->assertStringContainsString('protected $returnType = \App\Entities\User::class;', $this->getFileContent($file)); if (is_file($file)) { unlink($file); diff --git a/tests/system/Models/InsertOnlyFieldsModelTest.php b/tests/system/Models/InsertOnlyFieldsModelTest.php new file mode 100644 index 000000000000..93d6d0397382 --- /dev/null +++ b/tests/system/Models/InsertOnlyFieldsModelTest.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use CodeIgniter\Database\Exceptions\DataException; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Entity\User; +use Tests\Support\Models\UserModel; +use Tests\Support\Models\ValidModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class InsertOnlyFieldsModelTest extends LiveModelTestCase +{ + public function testInsertAllowsInsertOnlyFields(): void + { + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->insert([ + 'name' => 'Insert Only', + 'email' => 'insert-only@example.com', + 'country' => 'US', + ]); + + $this->seeInDatabase('user', [ + 'email' => 'insert-only@example.com', + ]); + } + + public function testInsertBatchAllowsInsertOnlyFields(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->insertBatch([ + [ + 'name' => 'Insert Only Batch', + 'email' => 'insert-only-batch@example.com', + 'country' => 'US', + ], + ]); + + $this->assertSame(1, $result); + $this->seeInDatabase('user', [ + 'email' => 'insert-only-batch@example.com', + ]); + } + + public function testUpdateDiscardsInsertOnlyFieldsByDefault(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->update(1, [ + 'name' => 'Insert Only Update', + 'email' => 'insert-only-update@example.com', + ]); + + $this->assertTrue($result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Insert Only Update', + 'email' => 'derek@world.com', + ]); + } + + public function testThrowOnDisallowedFieldsThrowsOnInsertOnlyFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->throwOnDisallowedFields()->update(1, [ + 'name' => 'Insert Only Update', + 'email' => 'insert-only-update@example.com', + ]); + } + + public function testSaveUpdateDiscardsInsertOnlyFieldsByDefault(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->save([ + 'id' => 1, + 'name' => 'Insert Only Save', + 'email' => 'insert-only-save@example.com', + ]); + + $this->assertTrue($result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Insert Only Save', + 'email' => 'derek@world.com', + ]); + } + + public function testSaveUpdateThrowsOnInsertOnlyFieldsWhenThrowingOnDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->throwOnDisallowedFields()->save([ + 'id' => 1, + 'name' => 'Insert Only Save', + 'email' => 'insert-only-save@example.com', + ]); + } + + public function testSetUpdateDiscardsInsertOnlyFieldsByDefault(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email']) + ->where('id', 1) + ->set('email', 'insert-only-set@example.com') + ->update(null, ['name' => 'Insert Only Set']); + + $this->assertTrue($result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Insert Only Set', + 'email' => 'derek@world.com', + ]); + } + + public function testSetUpdateThrowsOnInsertOnlyFieldsWhenThrowingOnDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->throwOnDisallowedFields() + ->where('id', 1) + ->set('email', 'insert-only-set@example.com') + ->update(null, ['name' => 'Insert Only Set']); + } + + public function testUpdateBatchDiscardsInsertOnlyFieldsByDefault(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->updateBatch([ + [ + 'id' => 1, + 'name' => 'Insert Only Batch', + 'email' => 'insert-only-update-batch@example.com', + ], + ], 'id'); + + $this->assertSame(1, $result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Insert Only Batch', + 'email' => 'derek@world.com', + ]); + } + + public function testUpdateBatchThrowsOnInsertOnlyFieldsWhenThrowingOnDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->throwOnDisallowedFields()->updateBatch([ + [ + 'id' => 1, + 'name' => 'Insert Only Batch', + 'email' => 'insert-only-update-batch@example.com', + ], + ], 'id'); + } + + public function testUpdateBatchAllowsInsertOnlyFieldAsIndex(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->updateBatch([ + [ + 'email' => 'derek@world.com', + 'name' => 'Insert Only Batch Index', + ], + ], 'email'); + + $this->assertSame(1, $result); + $this->seeInDatabase('user', [ + 'email' => 'derek@world.com', + 'name' => 'Insert Only Batch Index', + ]); + } + + public function testEntityUpdateDiscardsChangedInsertOnlyFieldsByDefault(): void + { + $model = new class ($this->db) extends UserModel { + protected $returnType = User::class; + protected array $insertOnlyFields = ['email']; + }; + + $user = $model->find(1); + $this->assertInstanceOf(User::class, $user); + + $user->email = 'insert-only-entity@example.com'; + $user->name = 'Insert Only Entity'; + + $this->assertTrue($model->update($user->id, $user)); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Insert Only Entity', + 'email' => 'derek@world.com', + ]); + } + + public function testEntityUpdateAllowsUnchangedInsertOnlyFields(): void + { + $model = new class ($this->db) extends UserModel { + protected $returnType = User::class; + protected array $insertOnlyFields = ['email']; + }; + + $user = $model->find(1); + $this->assertInstanceOf(User::class, $user); + + $user->name = 'Insert Only Entity'; + + $this->assertTrue($model->update($user->id, $user)); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Insert Only Entity', + ]); + } + + public function testProtectFalseBypassesInsertOnlyFields(): void + { + $result = $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->protect(false)->update(1, [ + 'email' => 'insert-only-disabled@example.com', + ]); + + $this->assertTrue($result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'email' => 'insert-only-disabled@example.com', + ]); + } + + public function testValidationRunsBeforeInsertOnlyFields(): void + { + $model = $this->createModel(ValidModel::class)->setInsertOnlyFields(['description']); + $this->setPrivateProperty($model, 'cleanValidationRules', false); + + $this->assertFalse($model->update(1, [ + 'description' => 'Insert only description', + ])); + $this->assertArrayHasKey('name', $model->errors()); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e145c14cacbc..8937fa460846 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -274,6 +274,7 @@ Model - Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks. - Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given attributes or inserts a new one. See :ref:`model-first-or-insert`. +- Added ``$insertOnlyFields`` and ``setInsertOnlyFields()`` to ``CodeIgniter\Model`` to remove configured fields from Model update operations while allowing them during inserts. See :ref:`model-insert-only-fields`. - Added ``$throwOnDisallowedFields`` and ``throwOnDisallowedFields()`` to ``CodeIgniter\Model`` to throw a ``DataException`` when write data contains fields that would otherwise be discarded by ``$allowedFields``. See :ref:`model-throw-on-disallowed-fields`. Libraries diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 648d5b9c9b01..d16e7a779223 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -163,6 +163,36 @@ potential mass assignment vulnerabilities. .. note:: The `$primaryKey`_ field should never be an allowed field. +.. _model-insert-only-fields: + +$insertOnlyFields +----------------- + +.. versionadded:: 4.8.0 + +This array may contain fields that can be set during ``insert()`` and +``insertBatch()`` calls, but should be removed from ``update()``, +``updateBatch()``, and update-side ``save()`` calls. + +This is useful for values that should be created once and then left unchanged +through normal Model writes, such as public IDs, external references, or +generated slugs. + +.. literalinclude:: model/069.php + +Fields listed here must also be listed in `$allowedFields`_ when field +protection is enabled. This is Model-level protection only. It does not create a +database constraint, does not inspect previous database values, and does not +intercept ``replace()`` or direct Query Builder writes. Calling +``protect(false)`` disables this protection. + +By default, insert-only fields are discarded from update data, the same way +fields outside `$allowedFields`_ are discarded. When `$throwOnDisallowedFields`_ +is enabled, submitting an insert-only field during an update operation throws a +``DataException``. + +You may also change this setting with the ``setInsertOnlyFields()`` method. + .. _model-throw-on-disallowed-fields: $throwOnDisallowedFields @@ -944,6 +974,11 @@ When throwing on disallowed fields is enabled, operation fields such as the primary key passed to ``update()`` or the index passed to ``updateBatch()`` may still be used to locate rows. +If some allowed fields should only be set during insert operations, list them +in `$insertOnlyFields`_: + +.. literalinclude:: model/069.php + Occasionally, you will find times where you need to be able to change these elements. This is often during testing, migrations, or seeds. In these cases, you can turn the protection on or off: diff --git a/user_guide_src/source/models/model/005.php b/user_guide_src/source/models/model/005.php index 5e437a3f1ffd..a8adcdafd124 100644 --- a/user_guide_src/source/models/model/005.php +++ b/user_guide_src/source/models/model/005.php @@ -14,7 +14,8 @@ class UserModel extends Model protected $returnType = 'array'; protected $useSoftDeletes = true; - protected $allowedFields = ['name', 'email']; + protected $allowedFields = ['name', 'email']; + protected array $insertOnlyFields = []; protected bool $allowEmptyInserts = false; protected bool $updateOnlyChanged = true; diff --git a/user_guide_src/source/models/model/069.php b/user_guide_src/source/models/model/069.php new file mode 100644 index 000000000000..67e2988d237a --- /dev/null +++ b/user_guide_src/source/models/model/069.php @@ -0,0 +1,12 @@ +