Skip to content
Open
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
58 changes: 57 additions & 1 deletion system/BaseModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ abstract class BaseModel
*/
protected $allowedFields = [];

/**
* Fields that may be inserted but not updated through Model write methods.
*
* @var list<string>
*/
protected $insertOnlyFields = [];

/**
* If true, will set created_at, and updated_at
* values during insert and update routines.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1375,6 +1385,20 @@ public function setAllowedFields(array $allowedFields)
return $this;
}

/**
* Sets the fields that may be inserted but not updated.
*
* @param list<string> $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.
Expand Down Expand Up @@ -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<string> $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.
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions system/Commands/Generators/Views/model.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions system/Database/Exceptions/DataException.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ public static function forDisallowedFields(string $model, array $fields)
return new static(lang('Database.disallowedFields', [$model, implode(', ', $fields)]));
}

/**
* @param list<string> $fields
*
* @return DataException
*/
public static function forInsertOnlyFields(string $model, array $fields)
{
return new static(lang('Database.insertOnlyFields', [$model, implode(', ', $fields)]));
}

/**
* @return DataException
*/
Expand Down
1 change: 1 addition & 0 deletions system/Language/en/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
181 changes: 181 additions & 0 deletions tests/system/Models/InsertOnlyFieldsModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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());
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions user_guide_src/source/models/model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion user_guide_src/source/models/model/005.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions user_guide_src/source/models/model/069.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Models;

use CodeIgniter\Model;

class UserModel extends Model
{
protected $allowedFields = ['public_id', 'name', 'email'];

protected $insertOnlyFields = ['public_id'];
}
Loading