Skip to content

Commit ad75bb0

Browse files
authored
feat: add Model withLockedRow() (#10388)
* feat(model): add locked row callback helper - add withLockedRow() for running a callback with a row locked for update - bypass find callbacks for the locked reload while preserving normal callback behavior inside the callback - clean up model state when the locked lookup fails - document transaction, constraint, and driver-lock behavior - add focused unit and live model coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * test: move locked row test doubles to support files Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * test: satisfy rector for locked row helper Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * fix rebase leftover Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs: clarify locked row rollback semantics Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * fix linter Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 2ff3fc8 commit ad75bb0

8 files changed

Lines changed: 537 additions & 0 deletions

File tree

system/Model.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use Config\Feature;
3333
use Generator;
3434
use stdClass;
35+
use Throwable;
3536

3637
/**
3738
* The Model class extends BaseModel and provides additional
@@ -574,6 +575,28 @@ public function doesntExist(bool $reset = true, bool $test = false)
574575
return $this->builder()->testMode($test)->doesntExist($reset);
575576
}
576577

578+
/**
579+
* Runs the callback with a row reloaded inside a transaction and locked for update.
580+
*
581+
* @template TReturn
582+
*
583+
* @param callable(object|row_array, static): TReturn $callback
584+
*
585+
* @return false|TReturn|null
586+
*/
587+
public function withLockedRow(int|string $id, callable $callback): mixed
588+
{
589+
return $this->db->transaction(function () use ($id, $callback): mixed {
590+
$row = $this->findLockedRow($id);
591+
592+
if ($row === null) {
593+
return null;
594+
}
595+
596+
return $callback($row, $this);
597+
});
598+
}
599+
577600
/**
578601
* Applies the Model soft-delete constraint before terminal Builder operations.
579602
*/
@@ -591,6 +614,30 @@ private function prepareSoftDeleteQuery(bool $reset): void
591614
: ($this->useSoftDeletes ? false : $this->useSoftDeletes);
592615
}
593616

617+
/**
618+
* Reloads a row with a database lock, without allowing find callbacks to short-circuit it.
619+
*/
620+
private function findLockedRow(int|string $id): mixed
621+
{
622+
$builder = $this->builder();
623+
$tempAllowCallbacks = $this->tempAllowCallbacks;
624+
$this->tempAllowCallbacks = false;
625+
626+
try {
627+
$builder->lockForUpdate();
628+
629+
return $this->find($id);
630+
} catch (Throwable $e) {
631+
$this->builder = null;
632+
$this->tempReturnType = $this->returnType;
633+
$this->tempUseSoftDeletes = $this->useSoftDeletes;
634+
635+
throw $e;
636+
} finally {
637+
$this->tempAllowCallbacks = $tempAllowCallbacks;
638+
}
639+
}
640+
594641
/**
595642
* Iterates over the result set in chunks of the specified size.
596643
*
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Models;
15+
16+
use CodeIgniter\Database\BaseBuilder;
17+
use CodeIgniter\Database\BaseResult;
18+
use CodeIgniter\Database\Exceptions\DatabaseException;
19+
use CodeIgniter\Database\Query;
20+
use CodeIgniter\Database\TableName;
21+
use CodeIgniter\Test\Mock\MockConnection;
22+
use stdClass;
23+
24+
/**
25+
* @internal
26+
*/
27+
final class WithLockedRowConnection extends MockConnection
28+
{
29+
/**
30+
* @param list<array<string, mixed>> $rows
31+
*/
32+
public function __construct(private readonly array $rows = [], public bool $throwOnSelect = false)
33+
{
34+
parent::__construct([]);
35+
}
36+
37+
/**
38+
* @param array<int|string, mixed>|string|null $binds
39+
*/
40+
public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = ''): BaseResult|bool
41+
{
42+
if ($this->connID === false || $this->connID === null) {
43+
$this->initialize();
44+
}
45+
46+
$query = new Query($this);
47+
$query->setQuery($sql, $binds, $setEscapeFlags);
48+
49+
$this->lastQuery = $query;
50+
51+
if ($query->isWriteType()) {
52+
return true;
53+
}
54+
55+
if ($this->throwOnSelect) {
56+
throw new DatabaseException('Locked lookup failed.');
57+
}
58+
59+
return new WithLockedRowResult($this->connID, new stdClass(), $this->rows);
60+
}
61+
62+
/**
63+
* @param array<array-key, mixed>|string|TableName $tableName
64+
*/
65+
public function table($tableName): BaseBuilder
66+
{
67+
return new BaseBuilder($tableName, $this);
68+
}
69+
70+
protected function execute(string $sql): object
71+
{
72+
return new stdClass();
73+
}
74+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Models;
15+
16+
use CodeIgniter\Database\BaseResult;
17+
use stdClass;
18+
19+
/**
20+
* @internal
21+
*
22+
* @extends BaseResult<object|resource, object|resource>
23+
*/
24+
final class WithLockedRowResult extends BaseResult
25+
{
26+
/**
27+
* @param list<array<string, mixed>> $rows
28+
* @param mixed $connID
29+
* @param mixed $resultID
30+
*/
31+
public function __construct($connID, $resultID, private array $rows)
32+
{
33+
parent::__construct($connID, $resultID);
34+
}
35+
36+
public function getFieldCount(): int
37+
{
38+
return 0;
39+
}
40+
41+
/**
42+
* @return list<string>
43+
*/
44+
public function getFieldNames(): array
45+
{
46+
return [];
47+
}
48+
49+
/**
50+
* @return list<object>
51+
*/
52+
public function getFieldData(): array
53+
{
54+
return [];
55+
}
56+
57+
public function freeResult(): void
58+
{
59+
}
60+
61+
public function dataSeek(int $n = 0): bool
62+
{
63+
return true;
64+
}
65+
66+
/**
67+
* @return array<string, mixed>|false
68+
*/
69+
protected function fetchAssoc(): array|false
70+
{
71+
return array_shift($this->rows) ?? false;
72+
}
73+
74+
protected function fetchObject($className = stdClass::class): false|object
75+
{
76+
$row = $this->fetchAssoc();
77+
78+
if ($row === false) {
79+
return false;
80+
}
81+
82+
$object = new $className();
83+
84+
foreach ($row as $key => $value) {
85+
$object->{$key} = $value;
86+
}
87+
88+
return $object;
89+
}
90+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Models;
15+
16+
use PHPUnit\Framework\Attributes\Group;
17+
use RuntimeException;
18+
use Tests\Support\Models\EventModel;
19+
use Tests\Support\Models\UserModel;
20+
21+
/**
22+
* @internal
23+
*/
24+
#[Group('DatabaseLive')]
25+
final class WithLockedRowModelTest extends LiveModelTestCase
26+
{
27+
protected function setUp(): void
28+
{
29+
parent::setUp();
30+
31+
if ($this->db->DBDriver === 'SQLite3') {
32+
$this->markTestSkipped('SQLite3 does not support lockForUpdate().');
33+
}
34+
}
35+
36+
public function testWithLockedRowReturnsCallbackResult(): void
37+
{
38+
$inTransaction = false;
39+
$model = $this->createModel(UserModel::class);
40+
41+
$result = $model->withLockedRow(1, static function (object $user, UserModel $model) use (&$inTransaction): string {
42+
$inTransaction = $model->db->inTransaction();
43+
44+
return $user->email;
45+
});
46+
47+
$this->assertSame('derek@world.com', $result);
48+
$this->assertTrue($inTransaction);
49+
$this->assertFalse($this->db->inTransaction());
50+
}
51+
52+
public function testWithLockedRowDoesNotRunCallbackWhenRowIsMissing(): void
53+
{
54+
$callbackRan = false;
55+
$model = $this->createModel(UserModel::class);
56+
57+
$result = $model->withLockedRow(999, static function () use (&$callbackRan): void {
58+
$callbackRan = true;
59+
});
60+
61+
$this->assertNull($result);
62+
$this->assertFalse($callbackRan);
63+
}
64+
65+
public function testWithLockedRowAppliesExistingQueryConstraints(): void
66+
{
67+
$model = $this->createModel(UserModel::class);
68+
69+
$result = $model->where('country', 'CA')->withLockedRow(1, static fn (): string => 'locked');
70+
71+
$this->assertNull($result);
72+
}
73+
74+
public function testWithLockedRowRespectsSoftDeletes(): void
75+
{
76+
$model = $this->createModel(UserModel::class);
77+
$model->delete(1);
78+
79+
$result = $model->withLockedRow(1, static fn (): string => 'locked');
80+
81+
$this->assertNull($result);
82+
}
83+
84+
public function testWithLockedRowCanIncludeSoftDeletedRows(): void
85+
{
86+
$model = $this->createModel(UserModel::class);
87+
$model->delete(1);
88+
89+
$result = $model->withDeleted()->withLockedRow(1, static fn (object $user): string => $user->email);
90+
91+
$this->assertSame('derek@world.com', $result);
92+
}
93+
94+
public function testWithLockedRowRollsBackWhenCallbackThrows(): void
95+
{
96+
$model = $this->createModel(UserModel::class);
97+
98+
try {
99+
$model->withLockedRow(1, static function (object $user, UserModel $model): void {
100+
$model->update($user->id, ['name' => 'Rolled Back']);
101+
102+
throw new RuntimeException('Stop transaction.');
103+
});
104+
} catch (RuntimeException $e) {
105+
$this->assertSame('Stop transaction.', $e->getMessage());
106+
}
107+
108+
$this->seeInDatabase('user', [
109+
'id' => 1,
110+
'name' => 'Derek Jones',
111+
]);
112+
}
113+
114+
public function testWithLockedRowBypassesFindCallbacks(): void
115+
{
116+
$model = $this->createModel(EventModel::class);
117+
$model->beforeFindReturnData = true;
118+
119+
$result = $model->withLockedRow(1, static fn (array $user): string => $user['email']);
120+
121+
$this->assertSame('derek@world.com', $result);
122+
$this->assertFalse($model->hasToken('beforeFind'));
123+
$this->assertFalse($model->hasToken('afterFind'));
124+
}
125+
126+
public function testWithLockedRowRestoresCallbacksBeforeRunningCallback(): void
127+
{
128+
$model = $this->createModel(EventModel::class);
129+
130+
$model->withLockedRow(1, static function (array $user, EventModel $model): void {
131+
$model->update($user['id'], ['name' => 'Locked Update']);
132+
});
133+
134+
$this->assertTrue($model->hasToken('beforeUpdate'));
135+
$this->assertTrue($model->hasToken('afterUpdate'));
136+
}
137+
138+
public function testWithLockedRowDoesNotAddLimitToLockedLookup(): void
139+
{
140+
$model = $this->createModel(UserModel::class);
141+
142+
$model->withLockedRow(1, static fn (): string => 'locked');
143+
144+
$sql = strtoupper((string) $this->db->getLastQuery());
145+
146+
$this->assertStringNotContainsString(' LIMIT ', $sql);
147+
$this->assertStringNotContainsString(' OFFSET ', $sql);
148+
}
149+
}

0 commit comments

Comments
 (0)