Skip to content

Commit 7dcbeb8

Browse files
authored
feat: add Query Builder lockForUpdate() (#10171)
* feat(database): add query builder lockForUpdate Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs(database): fix query builder lockForUpdate heading level Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs(database): clarify lockForUpdate driver notes Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs(database): refine lockForUpdate driver warnings Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * fix(database): validate lockForUpdate query restrictions - add driver validation for Postgre and OCI8 unsupported query shapes - reject lockForUpdate with union queries across supported builders - track Query Builder aggregate helper selections for lock validation - document row-locking restrictions and add focused builder coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * test(database): reject MySQLi lockForUpdate with fromSubquery Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs(database): clarify lockForUpdate transaction scope Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 8640420 commit 7dcbeb8

11 files changed

Lines changed: 509 additions & 25 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ class BaseBuilder
110110
*/
111111
protected $QBOffset = false;
112112

113+
/**
114+
* QB FOR UPDATE flag
115+
*/
116+
protected bool $QBLockForUpdate = false;
117+
118+
/**
119+
* QB SELECT aggregate helper flag
120+
*/
121+
protected bool $QBSelectUsesAggregate = false;
122+
113123
/**
114124
* QB ORDER BY data
115125
*
@@ -542,8 +552,9 @@ protected function maxMinAvgSum(string $select = '', string $alias = '', string
542552

543553
$sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias));
544554

545-
$this->QBSelect[] = $sql;
546-
$this->QBNoEscape[] = null;
555+
$this->QBSelect[] = $sql;
556+
$this->QBNoEscape[] = null;
557+
$this->QBSelectUsesAggregate = true;
547558

548559
return $this;
549560
}
@@ -1620,6 +1631,16 @@ public function limit(?int $value = null, ?int $offset = 0)
16201631
return $this;
16211632
}
16221633

1634+
/**
1635+
* Locks the selected rows for update.
1636+
*/
1637+
public function lockForUpdate(): static
1638+
{
1639+
$this->QBLockForUpdate = true;
1640+
1641+
return $this;
1642+
}
1643+
16231644
/**
16241645
* Sets the OFFSET value
16251646
*
@@ -1801,20 +1822,26 @@ public function countAllResults(bool $reset = true)
18011822
}
18021823

18031824
// We cannot use a LIMIT when getting the single row COUNT(*) result
1804-
$limit = $this->QBLimit;
1825+
$limit = $this->QBLimit;
1826+
$lockForUpdate = $this->QBLockForUpdate;
18051827

1806-
$this->QBLimit = false;
1828+
$this->QBLimit = false;
1829+
$this->QBLockForUpdate = false;
18071830

1808-
if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
1809-
// We need to backup the original SELECT in case DBPrefix is used
1810-
$select = $this->QBSelect;
1811-
$sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
1831+
try {
1832+
if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
1833+
// We need to backup the original SELECT in case DBPrefix is used
1834+
$select = $this->QBSelect;
1835+
$sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
18121836

1813-
// Restore SELECT part
1814-
$this->QBSelect = $select;
1815-
unset($select);
1816-
} else {
1817-
$sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
1837+
// Restore SELECT part
1838+
$this->QBSelect = $select;
1839+
unset($select);
1840+
} else {
1841+
$sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
1842+
}
1843+
} finally {
1844+
$this->QBLockForUpdate = $lockForUpdate;
18181845
}
18191846

18201847
if ($this->testMode) {
@@ -3223,9 +3250,23 @@ protected function compileSelect($selectOverride = false): string
32233250
$sql = $this->_limit($sql . "\n");
32243251
}
32253252

3253+
$sql .= $this->compileLockForUpdate();
3254+
32263255
return $this->unionInjection($sql);
32273256
}
32283257

3258+
/**
3259+
* Compile the SELECT lock clause.
3260+
*/
3261+
protected function compileLockForUpdate(): string
3262+
{
3263+
if ($this->QBLockForUpdate && $this->QBUnion !== []) {
3264+
throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
3265+
}
3266+
3267+
return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
3268+
}
3269+
32293270
/**
32303271
* Checks if the ignore option is supported by
32313272
* the Database Driver for the specific statement.
@@ -3533,17 +3574,19 @@ protected function resetRun(array $qbResetItems)
35333574
protected function resetSelect()
35343575
{
35353576
$this->resetRun([
3536-
'QBSelect' => [],
3537-
'QBJoin' => [],
3538-
'QBWhere' => [],
3539-
'QBGroupBy' => [],
3540-
'QBHaving' => [],
3541-
'QBOrderBy' => [],
3542-
'QBNoEscape' => [],
3543-
'QBDistinct' => false,
3544-
'QBLimit' => false,
3545-
'QBOffset' => false,
3546-
'QBUnion' => [],
3577+
'QBSelect' => [],
3578+
'QBJoin' => [],
3579+
'QBWhere' => [],
3580+
'QBGroupBy' => [],
3581+
'QBHaving' => [],
3582+
'QBOrderBy' => [],
3583+
'QBNoEscape' => [],
3584+
'QBDistinct' => false,
3585+
'QBLimit' => false,
3586+
'QBOffset' => false,
3587+
'QBLockForUpdate' => false,
3588+
'QBSelectUsesAggregate' => false,
3589+
'QBUnion' => [],
35473590
]);
35483591

35493592
if ($this->db instanceof BaseConnection) {

system/Database/MySQLi/Builder.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ protected function _fromTables(): string
5858
return implode(', ', $this->QBFrom);
5959
}
6060

61+
/**
62+
* Compile the SELECT lock clause.
63+
*/
64+
protected function compileLockForUpdate(): string
65+
{
66+
if (! $this->QBLockForUpdate) {
67+
return '';
68+
}
69+
70+
foreach ($this->QBFrom as $value) {
71+
if (str_starts_with($value, '(SELECT')) {
72+
throw new DatabaseException('MySQLi does not support lockForUpdate() with fromSubquery().');
73+
}
74+
}
75+
76+
return parent::compileLockForUpdate();
77+
}
78+
6179
/**
6280
* Generates a platform-specific batch update string from the supplied data
6381
*/

system/Database/OCI8/Builder.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,26 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string
213213
return $sql . ' OFFSET ' . $offset . ' ROWS FETCH NEXT ' . $this->QBLimit . ' ROWS ONLY';
214214
}
215215

216+
/**
217+
* Compile the SELECT lock clause.
218+
*/
219+
protected function compileLockForUpdate(): string
220+
{
221+
if (! $this->QBLockForUpdate) {
222+
return '';
223+
}
224+
225+
if ($this->QBLimit !== false || $this->QBOffset) {
226+
throw new DatabaseException('OCI8 does not support lockForUpdate() with limit() or offset().');
227+
}
228+
229+
if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) {
230+
throw new DatabaseException('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.');
231+
}
232+
233+
return parent::compileLockForUpdate();
234+
}
235+
216236
/**
217237
* Generates a platform-specific batch update string from the supplied data
218238
*/

system/Database/Postgre/Builder.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ protected function compileIgnore(string $statement)
6262
return $sql;
6363
}
6464

65+
/**
66+
* Compile the SELECT lock clause.
67+
*/
68+
protected function compileLockForUpdate(): string
69+
{
70+
if (! $this->QBLockForUpdate) {
71+
return '';
72+
}
73+
74+
if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) {
75+
throw new DatabaseException('Postgre does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.');
76+
}
77+
78+
return parent::compileLockForUpdate();
79+
}
80+
6581
/**
6682
* ORDER BY
6783
*

system/Database/SQLSRV/Builder.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
*/
3434
class Builder extends BaseBuilder
3535
{
36+
private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)';
37+
3638
/**
3739
* ORDER BY random keyword
3840
*
@@ -76,7 +78,13 @@ protected function _fromTables(): string
7678
$from = [];
7779

7880
foreach ($this->QBFrom as $value) {
79-
$from[] = str_starts_with($value, '(SELECT') ? $value : $this->getFullName($value);
81+
if (str_starts_with($value, '(SELECT')) {
82+
$from[] = $value;
83+
84+
continue;
85+
}
86+
87+
$from[] = $this->getFullName($value) . ($this->QBLockForUpdate ? self::LOCK_FOR_UPDATE_HINT : '');
8088
}
8189

8290
return implode(', ', $from);
@@ -677,9 +685,37 @@ protected function compileSelect($selectOverride = false): string
677685
$sql = $this->_limit($sql . "\n");
678686
}
679687

688+
$sql .= $this->compileLockForUpdate();
689+
680690
return $this->unionInjection($sql);
681691
}
682692

693+
/**
694+
* Compile the SELECT lock clause.
695+
*/
696+
protected function compileLockForUpdate(): string
697+
{
698+
if (! $this->QBLockForUpdate) {
699+
return '';
700+
}
701+
702+
if ($this->QBFrom === []) {
703+
throw new DatabaseException('SQLSRV does not support lockForUpdate() without a FROM table.');
704+
}
705+
706+
if ($this->QBUnion !== []) {
707+
throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
708+
}
709+
710+
foreach ($this->QBFrom as $value) {
711+
if (str_starts_with($value, '(SELECT')) {
712+
throw new DatabaseException('SQLSRV does not support lockForUpdate() on subqueries.');
713+
}
714+
}
715+
716+
return '';
717+
}
718+
683719
/**
684720
* Compiles the select statement based on the other functions called
685721
* and runs the query

system/Database/SQLite3/Builder.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ class Builder extends BaseBuilder
5555
'insert' => 'OR IGNORE',
5656
];
5757

58+
/**
59+
* Compile the SELECT lock clause.
60+
*/
61+
protected function compileLockForUpdate(): string
62+
{
63+
if ($this->QBLockForUpdate) {
64+
throw new DatabaseException('SQLite3 does not support lockForUpdate().');
65+
}
66+
67+
return '';
68+
}
69+
5870
/**
5971
* Replace statement
6072
*

tests/system/Database/Builder/CountTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\Database\Builder;
1515

1616
use CodeIgniter\Database\BaseBuilder;
17+
use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder;
1718
use CodeIgniter\Test\CIUnitTestCase;
1819
use CodeIgniter\Test\Mock\MockConnection;
1920
use PHPUnit\Framework\Attributes\Group;
@@ -55,6 +56,34 @@ public function testCountAllResults(): void
5556
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
5657
}
5758

59+
public function testCountAllResultsDoesNotUseLockForUpdate(): void
60+
{
61+
$builder = new BaseBuilder('jobs', $this->db);
62+
$builder->testMode();
63+
64+
$answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false);
65+
66+
$expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:';
67+
68+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
69+
$this->assertSame('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', str_replace("\n", ' ', $builder->getCompiledSelect(false)));
70+
}
71+
72+
public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void
73+
{
74+
$this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']);
75+
76+
$builder = new SQLSRVBuilder('jobs', $this->db);
77+
$builder->testMode();
78+
79+
$answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false);
80+
81+
$expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "test"."dbo"."jobs" WHERE "id" > :id:';
82+
83+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
84+
$this->assertSame('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', str_replace("\n", ' ', $builder->getCompiledSelect(false)));
85+
}
86+
5887
public function testCountAllResultsWithGroupBy(): void
5988
{
6089
$builder = new BaseBuilder('jobs', $this->db);

0 commit comments

Comments
 (0)