diff --git a/system/BaseModel.php b/system/BaseModel.php index f5b0526eff1e..5d8db89812a1 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 $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]; + + $this->ensureNoInsertOnlyFields($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,37 @@ protected function ensureNoDisallowedFields(array $row, array $ignoredFields = [ } } + /** + * Throws when update data contains fields that may only be inserted. + * + * @param row_array $row + * @param list $ignoredFields + * + * @throws DataException + */ + protected function ensureNoInsertOnlyFields(array $row, array $ignoredFields = []): void + { + if (! $this->protectFields || $this->allowedFields === [] || $this->insertOnlyFields === []) { + return; + } + + $insertOnlyFields = []; + + foreach (array_keys($row) as $key) { + if (in_array($key, $ignoredFields, true)) { + continue; + } + + if (in_array($key, $this->insertOnlyFields, true)) { + $insertOnlyFields[] = $key; + } + } + + if ($insertOnlyFields !== []) { + throw DataException::forInsertOnlyFields(static::class, $insertOnlyFields); + } + } + /** * Ensures that only the fields that are allowed to be inserted are in * the data array. @@ -1496,6 +1551,7 @@ protected function doProtectFieldsForInsert(array $row): array */ protected function doProtectFieldsForUpdate(array $row): array { + $this->ensureNoInsertOnlyFields($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..49df4632863d 100644 --- a/system/Commands/Generators/Views/model.tpl.php +++ b/system/Commands/Generators/Views/model.tpl.php @@ -16,6 +16,7 @@ class {class} extends Model protected $useSoftDeletes = false; protected $protectFields = true; protected $allowedFields = []; + protected $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..dc33bab9965d 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 { + $this->ensureNoInsertOnlyFields($row, [$this->primaryKey]); $this->ensureNoDisallowedFields($row, [$this->primaryKey]); return $this->doProtectFields($row); diff --git a/tests/system/Models/InsertOnlyFieldsModelTest.php b/tests/system/Models/InsertOnlyFieldsModelTest.php new file mode 100644 index 000000000000..bc84918697e3 --- /dev/null +++ b/tests/system/Models/InsertOnlyFieldsModelTest.php @@ -0,0 +1,181 @@ + + * + * 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 testUpdateThrowsOnInsertOnlyFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->update(1, [ + 'name' => 'Insert Only Update', + 'email' => 'insert-only-update@example.com', + ]); + } + + public function testSaveUpdateThrowsOnInsertOnlyFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->save([ + 'id' => 1, + 'name' => 'Insert Only Save', + 'email' => 'insert-only-save@example.com', + ]); + } + + public function testSetUpdateThrowsOnInsertOnlyFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email']) + ->where('id', 1) + ->set('email', 'insert-only-set@example.com') + ->update(null, ['name' => 'Insert Only Set']); + } + + public function testUpdateBatchThrowsOnInsertOnlyFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "Tests\Support\Models\UserModel": email'); + + $this->createModel(UserModel::class)->setInsertOnlyFields(['email'])->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 testEntityUpdateThrowsOnChangedInsertOnlyFields(): void + { + $model = new class ($this->db) extends UserModel { + protected $returnType = User::class; + protected $insertOnlyFields = ['email']; + }; + + $user = $model->find(1); + $this->assertInstanceOf(User::class, $user); + + $user->email = 'insert-only-entity@example.com'; + + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields cannot be updated for model "' . $model::class . '": email'); + + $model->update($user->id, $user); + } + + public function testEntityUpdateAllowsUnchangedInsertOnlyFields(): void + { + $model = new class ($this->db) extends UserModel { + protected $returnType = User::class; + protected $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..8d9effe0a4e0 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 prevent configured fields from being submitted during Model update operations. 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..11ae6b721504 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -163,6 +163,31 @@ 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 cannot be submitted during ``update()``, +``updateBatch()``, or 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 direct Query Builder writes. Calling ``protect(false)`` disables this +protection. + +You may also change this setting with the ``setInsertOnlyFields()`` method. + .. _model-throw-on-disallowed-fields: $throwOnDisallowedFields @@ -944,6 +969,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..428a3068568c 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 $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..46fa8aed78ba --- /dev/null +++ b/user_guide_src/source/models/model/069.php @@ -0,0 +1,12 @@ +