Skip to content

Commit 7111c24

Browse files
authored
feat: add Query Builder lock wait modifiers (#10349)
* feat(query-builder): add lock wait modifiers Add nowait() and skipLocked() for pessimistic SELECT locks. - Support driver-specific SQL generation for MySQLi, Postgre, OCI8, and SQLSRV. - Reject unsupported lock/wait combinations explicitly. - Preserve lock wait state across compiled selects, exists(), and countAllResults(). - Document driver support and SQLSRV READPAST behavior. - Add focused Query Builder tests for generated SQL, reset behavior, and unsupported combinations. Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * refactor: refine SQLSRV skip locked support - Avoid incompatible SQLSRV table hints for lockForUpdate()->skipLocked() - Add live SQLSRV coverage for skip locked execution - Document pessimistic lock and wait modifier driver support - Clarify that existence/count probes do not apply locking modifiers Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * docs: clarify MySQLi lock wait requirements Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent b365090 commit 7111c24

14 files changed

Lines changed: 650 additions & 55 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ class BaseBuilder
3333
{
3434
use ConditionalTrait;
3535

36-
protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate';
37-
protected const SELECT_LOCK_SHARED = 'shared';
36+
protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate';
37+
protected const SELECT_LOCK_SHARED = 'shared';
38+
protected const SELECT_LOCK_WAIT_NOWAIT = 'nowait';
39+
protected const SELECT_LOCK_WAIT_SKIP_LOCKED = 'skipLocked';
3840

3941
/**
4042
* Reset DELETE data flag
@@ -119,6 +121,11 @@ class BaseBuilder
119121
*/
120122
protected ?string $QBSelectLock = null;
121123

124+
/**
125+
* QB SELECT lock wait behavior
126+
*/
127+
protected ?string $QBSelectLockWait = null;
128+
122129
/**
123130
* QB SELECT aggregate helper flag
124131
*/
@@ -2020,6 +2027,26 @@ public function sharedLock(): static
20202027
return $this;
20212028
}
20222029

2030+
/**
2031+
* Fails immediately when selected rows cannot be locked.
2032+
*/
2033+
public function nowait(): static
2034+
{
2035+
$this->QBSelectLockWait = self::SELECT_LOCK_WAIT_NOWAIT;
2036+
2037+
return $this;
2038+
}
2039+
2040+
/**
2041+
* Skips selected rows that cannot be locked immediately.
2042+
*/
2043+
public function skipLocked(): static
2044+
{
2045+
$this->QBSelectLockWait = self::SELECT_LOCK_WAIT_SKIP_LOCKED;
2046+
2047+
return $this;
2048+
}
2049+
20232050
/**
20242051
* Sets the OFFSET value
20252052
*
@@ -2283,18 +2310,22 @@ protected function doExists(bool $reset = true)
22832310
*/
22842311
protected function compileExists(): string
22852312
{
2313+
$this->assertSelectLockWaitHasLock();
2314+
22862315
// ORDER BY and SELECT locks are unnecessary for checking row existence,
22872316
// and can produce invalid or surprising SQL on some drivers.
2288-
$orderBy = $this->QBOrderBy;
2289-
$limit = $this->QBLimit;
2290-
$offset = $this->QBOffset;
2291-
$selectLock = $this->QBSelectLock;
2292-
$select = $this->QBSelect;
2293-
$noEscape = $this->QBNoEscape;
2294-
$needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
2295-
2296-
$this->QBOrderBy = null;
2297-
$this->QBSelectLock = null;
2317+
$orderBy = $this->QBOrderBy;
2318+
$limit = $this->QBLimit;
2319+
$offset = $this->QBOffset;
2320+
$selectLock = $this->QBSelectLock;
2321+
$selectLockWait = $this->QBSelectLockWait;
2322+
$select = $this->QBSelect;
2323+
$noEscape = $this->QBNoEscape;
2324+
$needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
2325+
2326+
$this->QBOrderBy = null;
2327+
$this->QBSelectLock = null;
2328+
$this->QBSelectLockWait = null;
22982329

22992330
if (! $needsSubquery && $this->QBLimit !== 0) {
23002331
$this->QBLimit = 1;
@@ -2312,12 +2343,13 @@ protected function compileExists(): string
23122343

23132344
return $this->compileSelect('SELECT 1');
23142345
} finally {
2315-
$this->QBOrderBy = $orderBy;
2316-
$this->QBLimit = $limit;
2317-
$this->QBOffset = $offset;
2318-
$this->QBSelectLock = $selectLock;
2319-
$this->QBSelect = $select;
2320-
$this->QBNoEscape = $noEscape;
2346+
$this->QBOrderBy = $orderBy;
2347+
$this->QBLimit = $limit;
2348+
$this->QBOffset = $offset;
2349+
$this->QBSelectLock = $selectLock;
2350+
$this->QBSelectLockWait = $selectLockWait;
2351+
$this->QBSelect = $select;
2352+
$this->QBNoEscape = $noEscape;
23212353
}
23222354
}
23232355

@@ -2329,6 +2361,8 @@ protected function compileExists(): string
23292361
*/
23302362
public function countAllResults(bool $reset = true)
23312363
{
2364+
$this->assertSelectLockWaitHasLock();
2365+
23322366
// ORDER BY usage is often problematic here (most notably
23332367
// on Microsoft SQL Server) and ultimately unnecessary
23342368
// for selecting COUNT(*) ...
@@ -2341,11 +2375,13 @@ public function countAllResults(bool $reset = true)
23412375
}
23422376

23432377
// We cannot use a LIMIT when getting the single row COUNT(*) result
2344-
$limit = $this->QBLimit;
2345-
$selectLock = $this->QBSelectLock;
2378+
$limit = $this->QBLimit;
2379+
$selectLock = $this->QBSelectLock;
2380+
$selectLockWait = $this->QBSelectLockWait;
23462381

2347-
$this->QBLimit = false;
2348-
$this->QBSelectLock = null;
2382+
$this->QBLimit = false;
2383+
$this->QBSelectLock = null;
2384+
$this->QBSelectLockWait = null;
23492385

23502386
try {
23512387
if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
@@ -2360,7 +2396,8 @@ public function countAllResults(bool $reset = true)
23602396
$sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
23612397
}
23622398
} finally {
2363-
$this->QBSelectLock = $selectLock;
2399+
$this->QBSelectLock = $selectLock;
2400+
$this->QBSelectLockWait = $selectLockWait;
23642401
}
23652402

23662403
if ($this->testMode) {
@@ -3786,6 +3823,8 @@ protected function compileSelect($selectOverride = false): string
37863823
*/
37873824
protected function compileSelectLock(): string
37883825
{
3826+
$this->assertSelectLockWaitHasLock();
3827+
37893828
if ($this->QBSelectLock === null) {
37903829
return '';
37913830
}
@@ -3801,9 +3840,37 @@ protected function compileSelectLock(): string
38013840
self::SELECT_LOCK_FOR_UPDATE => "\nFOR UPDATE",
38023841
self::SELECT_LOCK_SHARED => "\nFOR SHARE",
38033842
default => throw new DatabaseException('Query Builder has an invalid SELECT lock mode.'),
3843+
} . $this->compileSelectLockWait();
3844+
}
3845+
3846+
/**
3847+
* Compile the SELECT lock wait behavior.
3848+
*/
3849+
protected function compileSelectLockWait(): string
3850+
{
3851+
return match ($this->QBSelectLockWait) {
3852+
self::SELECT_LOCK_WAIT_NOWAIT => ' NOWAIT',
3853+
self::SELECT_LOCK_WAIT_SKIP_LOCKED => ' SKIP LOCKED',
3854+
null => '',
3855+
default => throw new DatabaseException('Query Builder has an invalid SELECT lock wait behavior.'),
38043856
};
38053857
}
38063858

3859+
/**
3860+
* Ensures SELECT lock wait behavior has a pessimistic lock to modify.
3861+
*/
3862+
protected function assertSelectLockWaitHasLock(): void
3863+
{
3864+
if ($this->QBSelectLock !== null || $this->QBSelectLockWait === null) {
3865+
return;
3866+
}
3867+
3868+
throw new DatabaseException(sprintf(
3869+
'Query Builder does not support %s() without lockForUpdate() or sharedLock().',
3870+
$this->selectLockWaitMethod(),
3871+
));
3872+
}
3873+
38073874
/**
38083875
* Returns the public method name for the current SELECT lock mode.
38093876
*/
@@ -3816,6 +3883,18 @@ protected function selectLockMethod(): string
38163883
};
38173884
}
38183885

3886+
/**
3887+
* Returns the public method name for the current SELECT lock wait behavior.
3888+
*/
3889+
protected function selectLockWaitMethod(): string
3890+
{
3891+
return match ($this->QBSelectLockWait) {
3892+
self::SELECT_LOCK_WAIT_NOWAIT => 'nowait',
3893+
self::SELECT_LOCK_WAIT_SKIP_LOCKED => 'skipLocked',
3894+
default => 'selectLockWait',
3895+
};
3896+
}
3897+
38193898
/**
38203899
* Checks if the ignore option is supported by
38213900
* the Database Driver for the specific statement.
@@ -4160,6 +4239,7 @@ protected function resetSelect()
41604239
'QBLimit' => false,
41614240
'QBOffset' => false,
41624241
'QBSelectLock' => null,
4242+
'QBSelectLockWait' => null,
41634243
'QBSelectUsesAggregate' => false,
41644244
'QBUnion' => [],
41654245
]);

system/Database/MySQLi/Builder.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ protected function _fromTables(): string
6464
protected function compileSelectLock(): string
6565
{
6666
if ($this->QBSelectLock === null) {
67-
return '';
67+
return parent::compileSelectLock();
6868
}
6969

7070
foreach ($this->QBFrom as $value) {
@@ -77,6 +77,13 @@ protected function compileSelectLock(): string
7777
}
7878

7979
if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) {
80+
if ($this->QBSelectLockWait !== null) {
81+
throw new DatabaseException(sprintf(
82+
'MySQLi does not support sharedLock() with %s().',
83+
$this->selectLockWaitMethod(),
84+
));
85+
}
86+
8087
return "\nLOCK IN SHARE MODE";
8188
}
8289

system/Database/OCI8/Builder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string
219219
protected function compileSelectLock(): string
220220
{
221221
if ($this->QBSelectLock === null) {
222-
return '';
222+
return parent::compileSelectLock();
223223
}
224224

225225
if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) {

system/Database/Postgre/Builder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ protected function compileIgnore(string $statement)
6868
protected function compileSelectLock(): string
6969
{
7070
if ($this->QBSelectLock === null) {
71-
return '';
71+
return parent::compileSelectLock();
7272
}
7373

7474
if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) {

system/Database/SQLSRV/Builder.php

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333
*/
3434
class Builder extends BaseBuilder
3535
{
36-
private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)';
37-
private const SHARED_LOCK_HINT = ' WITH (HOLDLOCK, ROWLOCK)';
36+
private const LOCK_FOR_UPDATE_HINTS = ['UPDLOCK', 'ROWLOCK'];
37+
private const LOCK_FOR_UPDATE_SKIP_LOCKED_HINTS = ['UPDLOCK', 'READCOMMITTEDLOCK', 'READPAST'];
38+
private const SHARED_LOCK_HINTS = ['HOLDLOCK', 'ROWLOCK'];
3839

3940
/**
4041
* ORDER BY random keyword
@@ -96,11 +97,23 @@ protected function _fromTables(): string
9697
*/
9798
private function compileTableLockHint(): string
9899
{
99-
return match ($this->QBSelectLock) {
100-
self::SELECT_LOCK_FOR_UPDATE => self::LOCK_FOR_UPDATE_HINT,
101-
self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINT,
102-
default => '',
100+
$hints = match ($this->QBSelectLock) {
101+
self::SELECT_LOCK_FOR_UPDATE => $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED
102+
? self::LOCK_FOR_UPDATE_SKIP_LOCKED_HINTS
103+
: self::LOCK_FOR_UPDATE_HINTS,
104+
self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINTS,
105+
default => [],
103106
};
107+
108+
if ($hints === []) {
109+
return '';
110+
}
111+
112+
if ($this->QBSelectLockWait === self::SELECT_LOCK_WAIT_NOWAIT) {
113+
$hints[] = 'NOWAIT';
114+
}
115+
116+
return ' WITH (' . implode(', ', $hints) . ')';
104117
}
105118

106119
/**
@@ -639,7 +652,14 @@ protected function compileSelect($selectOverride = false): string
639652
protected function compileSelectLock(): string
640653
{
641654
if ($this->QBSelectLock === null) {
642-
return '';
655+
return parent::compileSelectLock();
656+
}
657+
658+
if (
659+
$this->QBSelectLock === self::SELECT_LOCK_SHARED
660+
&& $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED
661+
) {
662+
throw new DatabaseException('SQLSRV does not support sharedLock() with skipLocked().');
643663
}
644664

645665
if ($this->QBFrom === []) {

system/Database/SQLite3/Builder.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ class Builder extends BaseBuilder
6060
*/
6161
protected function compileSelectLock(): string
6262
{
63-
if ($this->QBSelectLock !== null) {
64-
throw new DatabaseException(sprintf(
65-
'SQLite3 does not support %s().',
66-
$this->selectLockMethod(),
67-
));
63+
if ($this->QBSelectLock === null) {
64+
return parent::compileSelectLock();
6865
}
6966

70-
return '';
67+
throw new DatabaseException(sprintf(
68+
'SQLite3 does not support %s().',
69+
$this->selectLockMethod(),
70+
));
7171
}
7272

7373
/**

tests/system/Database/Builder/CountTest.php

Lines changed: 15 additions & 4 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\Exceptions\DatabaseException;
1718
use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder;
1819
use CodeIgniter\Test\CIUnitTestCase;
1920
use CodeIgniter\Test\Mock\MockConnection;
@@ -61,12 +62,22 @@ public function testCountAllResultsDoesNotUseLockForUpdate(): void
6162
$builder = new BaseBuilder('jobs', $this->db);
6263
$builder->testMode();
6364

64-
$answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false);
65+
$answer = $builder->where('id >', 3)->lockForUpdate()->skipLocked()->countAllResults(false);
6566

6667
$expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:';
6768

6869
$this->assertSameSql($expectedSQL, $answer);
69-
$this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', $builder->getCompiledSelect(false));
70+
$this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE SKIP LOCKED', $builder->getCompiledSelect(false));
71+
}
72+
73+
public function testCountAllResultsThrowsExceptionWithSelectLockWaitWithoutSelectLock(): void
74+
{
75+
$builder = new BaseBuilder('jobs', $this->db);
76+
77+
$this->expectException(DatabaseException::class);
78+
$this->expectExceptionMessage('Query Builder does not support skipLocked() without lockForUpdate() or sharedLock().');
79+
80+
$builder->skipLocked()->countAllResults();
7081
}
7182

7283
public function testCountAllResultsDoesNotUseSharedLock(): void
@@ -89,12 +100,12 @@ public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void
89100
$builder = new SQLSRVBuilder('jobs', $this->db);
90101
$builder->testMode();
91102

92-
$answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false);
103+
$answer = $builder->where('id >', 3)->lockForUpdate()->nowait()->countAllResults(false);
93104

94105
$expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "test"."dbo"."jobs" WHERE "id" > :id:';
95106

96107
$this->assertSameSql($expectedSQL, $answer);
97-
$this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false));
108+
$this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK, NOWAIT) WHERE "id" > 3', $builder->getCompiledSelect(false));
98109
}
99110

100111
public function testCountAllResultsWithSQLSRVDoesNotUseSharedLock(): void

0 commit comments

Comments
 (0)