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
47 changes: 47 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Config\Feature;
use Generator;
use stdClass;
use Throwable;

/**
* The Model class extends BaseModel and provides additional
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
*
Expand Down
74 changes: 74 additions & 0 deletions tests/_support/Models/WithLockedRowConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?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 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<array<string, mixed>> $rows
*/
public function __construct(private readonly array $rows = [], public bool $throwOnSelect = false)
{
parent::__construct([]);
}

/**
* @param array<int|string, mixed>|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<array-key, mixed>|string|TableName $tableName
*/
public function table($tableName): BaseBuilder
{
return new BaseBuilder($tableName, $this);
}

protected function execute(string $sql): object
{
return new stdClass();
}
}
90 changes: 90 additions & 0 deletions tests/_support/Models/WithLockedRowResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?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 Tests\Support\Models;

use CodeIgniter\Database\BaseResult;
use stdClass;

/**
* @internal
*
* @extends BaseResult<object|resource, object|resource>
*/
final class WithLockedRowResult extends BaseResult
{
/**
* @param list<array<string, mixed>> $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<string>
*/
public function getFieldNames(): array
{
return [];
}

/**
* @return list<object>
*/
public function getFieldData(): array
{
return [];
}

public function freeResult(): void
{
}

public function dataSeek(int $n = 0): bool
{
return true;
}

/**
* @return array<string, mixed>|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;
}
}
149 changes: 149 additions & 0 deletions tests/system/Models/WithLockedRowModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?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 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);
}
}
Loading
Loading