diff --git a/system/Model.php b/system/Model.php index 37c19e524d86..7b184f85470f 100644 --- a/system/Model.php +++ b/system/Model.php @@ -32,6 +32,7 @@ use Config\Feature; use Generator; use stdClass; +use Throwable; /** * The Model class extends BaseModel and provides additional @@ -574,6 +575,28 @@ public function doesntExist(bool $reset = true, bool $test = false) return $this->builder()->testMode($test)->doesntExist($reset); } + /** + * Runs the callback with a row reloaded inside a transaction and locked for update. + * + * @template TReturn + * + * @param callable(object|row_array, static): TReturn $callback + * + * @return false|TReturn|null + */ + public function withLockedRow(int|string $id, callable $callback): mixed + { + return $this->db->transaction(function () use ($id, $callback): mixed { + $row = $this->findLockedRow($id); + + if ($row === null) { + return null; + } + + return $callback($row, $this); + }); + } + /** * Applies the Model soft-delete constraint before terminal Builder operations. */ @@ -591,6 +614,30 @@ private function prepareSoftDeleteQuery(bool $reset): void : ($this->useSoftDeletes ? false : $this->useSoftDeletes); } + /** + * Reloads a row with a database lock, without allowing find callbacks to short-circuit it. + */ + private function findLockedRow(int|string $id): mixed + { + $builder = $this->builder(); + $tempAllowCallbacks = $this->tempAllowCallbacks; + $this->tempAllowCallbacks = false; + + try { + $builder->lockForUpdate(); + + return $this->find($id); + } catch (Throwable $e) { + $this->builder = null; + $this->tempReturnType = $this->returnType; + $this->tempUseSoftDeletes = $this->useSoftDeletes; + + throw $e; + } finally { + $this->tempAllowCallbacks = $tempAllowCallbacks; + } + } + /** * Iterates over the result set in chunks of the specified size. * diff --git a/tests/_support/Models/WithLockedRowConnection.php b/tests/_support/Models/WithLockedRowConnection.php new file mode 100644 index 000000000000..cdde6286fa4c --- /dev/null +++ b/tests/_support/Models/WithLockedRowConnection.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Models; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Query; +use CodeIgniter\Database\TableName; +use CodeIgniter\Test\Mock\MockConnection; +use stdClass; + +/** + * @internal + */ +final class WithLockedRowConnection extends MockConnection +{ + /** + * @param list> $rows + */ + public function __construct(private readonly array $rows = [], public bool $throwOnSelect = false) + { + parent::__construct([]); + } + + /** + * @param array|string|null $binds + */ + public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = ''): BaseResult|bool + { + if ($this->connID === false || $this->connID === null) { + $this->initialize(); + } + + $query = new Query($this); + $query->setQuery($sql, $binds, $setEscapeFlags); + + $this->lastQuery = $query; + + if ($query->isWriteType()) { + return true; + } + + if ($this->throwOnSelect) { + throw new DatabaseException('Locked lookup failed.'); + } + + return new WithLockedRowResult($this->connID, new stdClass(), $this->rows); + } + + /** + * @param array|string|TableName $tableName + */ + public function table($tableName): BaseBuilder + { + return new BaseBuilder($tableName, $this); + } + + protected function execute(string $sql): object + { + return new stdClass(); + } +} diff --git a/tests/_support/Models/WithLockedRowResult.php b/tests/_support/Models/WithLockedRowResult.php new file mode 100644 index 000000000000..6f2b740b2684 --- /dev/null +++ b/tests/_support/Models/WithLockedRowResult.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Models; + +use CodeIgniter\Database\BaseResult; +use stdClass; + +/** + * @internal + * + * @extends BaseResult + */ +final class WithLockedRowResult extends BaseResult +{ + /** + * @param list> $rows + * @param mixed $connID + * @param mixed $resultID + */ + public function __construct($connID, $resultID, private array $rows) + { + parent::__construct($connID, $resultID); + } + + public function getFieldCount(): int + { + return 0; + } + + /** + * @return list + */ + public function getFieldNames(): array + { + return []; + } + + /** + * @return list + */ + public function getFieldData(): array + { + return []; + } + + public function freeResult(): void + { + } + + public function dataSeek(int $n = 0): bool + { + return true; + } + + /** + * @return array|false + */ + protected function fetchAssoc(): array|false + { + return array_shift($this->rows) ?? false; + } + + protected function fetchObject($className = stdClass::class): false|object + { + $row = $this->fetchAssoc(); + + if ($row === false) { + return false; + } + + $object = new $className(); + + foreach ($row as $key => $value) { + $object->{$key} = $value; + } + + return $object; + } +} diff --git a/tests/system/Models/WithLockedRowModelTest.php b/tests/system/Models/WithLockedRowModelTest.php new file mode 100644 index 000000000000..6ea05df4714b --- /dev/null +++ b/tests/system/Models/WithLockedRowModelTest.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Models\EventModel; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class WithLockedRowModelTest extends LiveModelTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + if ($this->db->DBDriver === 'SQLite3') { + $this->markTestSkipped('SQLite3 does not support lockForUpdate().'); + } + } + + public function testWithLockedRowReturnsCallbackResult(): void + { + $inTransaction = false; + $model = $this->createModel(UserModel::class); + + $result = $model->withLockedRow(1, static function (object $user, UserModel $model) use (&$inTransaction): string { + $inTransaction = $model->db->inTransaction(); + + return $user->email; + }); + + $this->assertSame('derek@world.com', $result); + $this->assertTrue($inTransaction); + $this->assertFalse($this->db->inTransaction()); + } + + public function testWithLockedRowDoesNotRunCallbackWhenRowIsMissing(): void + { + $callbackRan = false; + $model = $this->createModel(UserModel::class); + + $result = $model->withLockedRow(999, static function () use (&$callbackRan): void { + $callbackRan = true; + }); + + $this->assertNull($result); + $this->assertFalse($callbackRan); + } + + public function testWithLockedRowAppliesExistingQueryConstraints(): void + { + $model = $this->createModel(UserModel::class); + + $result = $model->where('country', 'CA')->withLockedRow(1, static fn (): string => 'locked'); + + $this->assertNull($result); + } + + public function testWithLockedRowRespectsSoftDeletes(): void + { + $model = $this->createModel(UserModel::class); + $model->delete(1); + + $result = $model->withLockedRow(1, static fn (): string => 'locked'); + + $this->assertNull($result); + } + + public function testWithLockedRowCanIncludeSoftDeletedRows(): void + { + $model = $this->createModel(UserModel::class); + $model->delete(1); + + $result = $model->withDeleted()->withLockedRow(1, static fn (object $user): string => $user->email); + + $this->assertSame('derek@world.com', $result); + } + + public function testWithLockedRowRollsBackWhenCallbackThrows(): void + { + $model = $this->createModel(UserModel::class); + + try { + $model->withLockedRow(1, static function (object $user, UserModel $model): void { + $model->update($user->id, ['name' => 'Rolled Back']); + + throw new RuntimeException('Stop transaction.'); + }); + } catch (RuntimeException $e) { + $this->assertSame('Stop transaction.', $e->getMessage()); + } + + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Derek Jones', + ]); + } + + public function testWithLockedRowBypassesFindCallbacks(): void + { + $model = $this->createModel(EventModel::class); + $model->beforeFindReturnData = true; + + $result = $model->withLockedRow(1, static fn (array $user): string => $user['email']); + + $this->assertSame('derek@world.com', $result); + $this->assertFalse($model->hasToken('beforeFind')); + $this->assertFalse($model->hasToken('afterFind')); + } + + public function testWithLockedRowRestoresCallbacksBeforeRunningCallback(): void + { + $model = $this->createModel(EventModel::class); + + $model->withLockedRow(1, static function (array $user, EventModel $model): void { + $model->update($user['id'], ['name' => 'Locked Update']); + }); + + $this->assertTrue($model->hasToken('beforeUpdate')); + $this->assertTrue($model->hasToken('afterUpdate')); + } + + public function testWithLockedRowDoesNotAddLimitToLockedLookup(): void + { + $model = $this->createModel(UserModel::class); + + $model->withLockedRow(1, static fn (): string => 'locked'); + + $sql = strtoupper((string) $this->db->getLastQuery()); + + $this->assertStringNotContainsString(' LIMIT ', $sql); + $this->assertStringNotContainsString(' OFFSET ', $sql); + } +} diff --git a/tests/system/Models/WithLockedRowUnitTest.php b/tests/system/Models/WithLockedRowUnitTest.php new file mode 100644 index 000000000000..bf43ca23ab97 --- /dev/null +++ b/tests/system/Models/WithLockedRowUnitTest.php @@ -0,0 +1,129 @@ + + * + * 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\DatabaseException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Models\EventModel; +use Tests\Support\Models\UserModel; +use Tests\Support\Models\WithLockedRowConnection; + +/** + * @internal + */ +#[Group('Others')] +final class WithLockedRowUnitTest extends CIUnitTestCase +{ + public function testWithLockedRowReturnsCallbackResultInsideTransactionAndLocksQuery(): void + { + $db = new WithLockedRowConnection([['id' => 1, 'email' => 'derek@world.com']]); + $model = new UserModel($db); + + $inTransaction = false; + $result = $model->withLockedRow(1, static function (object $user, UserModel $model) use (&$inTransaction): string { + $inTransaction = $model->db->inTransaction(); + + return $user->email; + }); + + $sql = (string) $db->getLastQuery(); + + $this->assertSame('derek@world.com', $result); + $this->assertTrue($inTransaction); + $this->assertFalse($db->inTransaction()); + $this->assertStringContainsString('FOR UPDATE', $sql); + $this->assertStringNotContainsString('LIMIT', strtoupper($sql)); + $this->assertStringNotContainsString('OFFSET', strtoupper($sql)); + } + + public function testWithLockedRowReturnsNullWithoutRunningCallbackWhenRowIsMissing(): void + { + $model = new UserModel(new WithLockedRowConnection()); + $callbackRan = false; + + $result = $model->withLockedRow(1, static function () use (&$callbackRan): void { + $callbackRan = true; + }); + + $this->assertNull($result); + $this->assertFalse($callbackRan); + } + + public function testWithLockedRowBypassesFindCallbacks(): void + { + $model = new EventModel(new WithLockedRowConnection([['id' => 1, 'email' => 'derek@world.com']])); + $model->beforeFindReturnData = true; + + $result = $model->withLockedRow(1, static fn (array $user): string => $user['email']); + + $this->assertSame('derek@world.com', $result); + $this->assertFalse($model->hasToken('beforeFind')); + $this->assertFalse($model->hasToken('afterFind')); + } + + public function testWithLockedRowRestoresCallbacksBeforeRunningCallback(): void + { + $model = new EventModel(new WithLockedRowConnection([['id' => 1, 'email' => 'derek@world.com']])); + + $model->withLockedRow(1, static function (array $user, EventModel $model): void { + $model->update($user['id'], ['name' => 'Locked Update']); + }); + + $this->assertTrue($model->hasToken('beforeUpdate')); + $this->assertTrue($model->hasToken('afterUpdate')); + } + + public function testWithLockedRowRollsBackWhenCallbackThrows(): void + { + $db = new WithLockedRowConnection([['id' => 1, 'email' => 'derek@world.com']]); + $model = new UserModel($db); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stop transaction.'); + + try { + $model->withLockedRow(1, static function (): void { + throw new RuntimeException('Stop transaction.'); + }); + } finally { + $this->assertFalse($db->inTransaction()); + } + } + + public function testWithLockedRowCleansUpModelStateWhenLockedLookupThrows(): void + { + $db = new WithLockedRowConnection([['id' => 1, 'email' => 'derek@world.com']], true); + $model = new UserModel($db); + + try { + $model->where('country', 'US')->withDeleted()->withLockedRow(1, static function (): void { + }); + + $this->fail('Expected locked lookup to throw.'); + } catch (DatabaseException $e) { + $this->assertSame('Locked lookup failed.', $e->getMessage()); + } + + $db->throwOnSelect = false; + + $result = $model->withLockedRow(1, static fn (object $user): string => $user->email); + $sql = (string) $db->getLastQuery(); + + $this->assertSame('derek@world.com', $result); + $this->assertStringContainsString('FOR UPDATE', $sql); + $this->assertStringNotContainsString('country', $sql); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 04c221b54396..d5966847cd46 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -279,6 +279,7 @@ Model - Added ``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`. +- Added ``withLockedRow()`` to ``CodeIgniter\Model`` to run a callback with one row reloaded inside a transaction using ``lockForUpdate()``. See :ref:`model-with-locked-row`. Libraries ========= diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 4c1b445468b0..6a118a8b3b32 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -771,6 +771,40 @@ error when ``DBDebug`` is ``false``). :php:class:`UniqueConstraintViolationException ` and resolved automatically by performing a second lookup. +.. _model-with-locked-row: + +withLockedRow() +--------------- + +.. versionadded:: 4.8.0 + +Reloads one row by primary key inside a transaction using +:ref:`query-builder-lock-for-update`, then runs the callback with the locked row +and the current Model instance: + +.. literalinclude:: model/073.php + +The method returns the callback return value. If the row is not found, the +callback is not run and ``null`` is returned. If the transaction cannot begin or +the transaction status fails without an exception, ``false`` is returned. + +Existing Model query constraints are applied to the locked lookup, so methods +like ``where()`` and ``withDeleted()`` can be called before ``withLockedRow()``. + +Any exception thrown by the callback rolls back the transaction and is rethrown. +Unsupported lock combinations throw the same database exceptions as +``lockForUpdate()``. + +The locked row is reloaded directly from the database, so Model find callbacks +are not run for that lookup. Model methods called inside the callback, such as +``save()`` or ``update()``, still follow the Model's callback setting. + +.. note:: Row locks are only useful when the database supports transactions and + pessimistic locks. See :ref:`query-builder-lock-for-update` for driver + support and limitations. If database transactions are disabled, this method + follows the behavior of ``transaction()`` and runs the callback without + starting a transaction. + .. _model-saving-dates: Saving Dates diff --git a/user_guide_src/source/models/model/073.php b/user_guide_src/source/models/model/073.php new file mode 100644 index 000000000000..20c513fd3533 --- /dev/null +++ b/user_guide_src/source/models/model/073.php @@ -0,0 +1,7 @@ +withLockedRow($id, static function (object $account, $model): bool { + $account->balance -= 100; + + return $model->save($account); +});