Skip to content

Commit fcc5a4d

Browse files
committed
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>
1 parent 4e8c5de commit fcc5a4d

13 files changed

Lines changed: 523 additions & 45 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
*/
@@ -2012,6 +2019,26 @@ public function sharedLock(): static
20122019
return $this;
20132020
}
20142021

2022+
/**
2023+
* Fails immediately when selected rows cannot be locked.
2024+
*/
2025+
public function nowait(): static
2026+
{
2027+
$this->QBSelectLockWait = self::SELECT_LOCK_WAIT_NOWAIT;
2028+
2029+
return $this;
2030+
}
2031+
2032+
/**
2033+
* Skips selected rows that cannot be locked immediately.
2034+
*/
2035+
public function skipLocked(): static
2036+
{
2037+
$this->QBSelectLockWait = self::SELECT_LOCK_WAIT_SKIP_LOCKED;
2038+
2039+
return $this;
2040+
}
2041+
20152042
/**
20162043
* Sets the OFFSET value
20172044
*
@@ -2275,18 +2302,22 @@ protected function doExists(bool $reset = true)
22752302
*/
22762303
protected function compileExists(): string
22772304
{
2305+
$this->assertSelectLockWaitHasLock();
2306+
22782307
// ORDER BY and SELECT locks are unnecessary for checking row existence,
22792308
// and can produce invalid or surprising SQL on some drivers.
2280-
$orderBy = $this->QBOrderBy;
2281-
$limit = $this->QBLimit;
2282-
$offset = $this->QBOffset;
2283-
$selectLock = $this->QBSelectLock;
2284-
$select = $this->QBSelect;
2285-
$noEscape = $this->QBNoEscape;
2286-
$needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
2287-
2288-
$this->QBOrderBy = null;
2289-
$this->QBSelectLock = null;
2309+
$orderBy = $this->QBOrderBy;
2310+
$limit = $this->QBLimit;
2311+
$offset = $this->QBOffset;
2312+
$selectLock = $this->QBSelectLock;
2313+
$selectLockWait = $this->QBSelectLockWait;
2314+
$select = $this->QBSelect;
2315+
$noEscape = $this->QBNoEscape;
2316+
$needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
2317+
2318+
$this->QBOrderBy = null;
2319+
$this->QBSelectLock = null;
2320+
$this->QBSelectLockWait = null;
22902321

22912322
if (! $needsSubquery && $this->QBLimit !== 0) {
22922323
$this->QBLimit = 1;
@@ -2304,12 +2335,13 @@ protected function compileExists(): string
23042335

23052336
return $this->compileSelect('SELECT 1');
23062337
} finally {
2307-
$this->QBOrderBy = $orderBy;
2308-
$this->QBLimit = $limit;
2309-
$this->QBOffset = $offset;
2310-
$this->QBSelectLock = $selectLock;
2311-
$this->QBSelect = $select;
2312-
$this->QBNoEscape = $noEscape;
2338+
$this->QBOrderBy = $orderBy;
2339+
$this->QBLimit = $limit;
2340+
$this->QBOffset = $offset;
2341+
$this->QBSelectLock = $selectLock;
2342+
$this->QBSelectLockWait = $selectLockWait;
2343+
$this->QBSelect = $select;
2344+
$this->QBNoEscape = $noEscape;
23132345
}
23142346
}
23152347

@@ -2321,6 +2353,8 @@ protected function compileExists(): string
23212353
*/
23222354
public function countAllResults(bool $reset = true)
23232355
{
2356+
$this->assertSelectLockWaitHasLock();
2357+
23242358
// ORDER BY usage is often problematic here (most notably
23252359
// on Microsoft SQL Server) and ultimately unnecessary
23262360
// for selecting COUNT(*) ...
@@ -2333,11 +2367,13 @@ public function countAllResults(bool $reset = true)
23332367
}
23342368

23352369
// We cannot use a LIMIT when getting the single row COUNT(*) result
2336-
$limit = $this->QBLimit;
2337-
$selectLock = $this->QBSelectLock;
2370+
$limit = $this->QBLimit;
2371+
$selectLock = $this->QBSelectLock;
2372+
$selectLockWait = $this->QBSelectLockWait;
23382373

2339-
$this->QBLimit = false;
2340-
$this->QBSelectLock = null;
2374+
$this->QBLimit = false;
2375+
$this->QBSelectLock = null;
2376+
$this->QBSelectLockWait = null;
23412377

23422378
try {
23432379
if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
@@ -2352,7 +2388,8 @@ public function countAllResults(bool $reset = true)
23522388
$sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
23532389
}
23542390
} finally {
2355-
$this->QBSelectLock = $selectLock;
2391+
$this->QBSelectLock = $selectLock;
2392+
$this->QBSelectLockWait = $selectLockWait;
23562393
}
23572394

23582395
if ($this->testMode) {
@@ -3778,6 +3815,8 @@ protected function compileSelect($selectOverride = false): string
37783815
*/
37793816
protected function compileSelectLock(): string
37803817
{
3818+
$this->assertSelectLockWaitHasLock();
3819+
37813820
if ($this->QBSelectLock === null) {
37823821
return '';
37833822
}
@@ -3793,9 +3832,37 @@ protected function compileSelectLock(): string
37933832
self::SELECT_LOCK_FOR_UPDATE => "\nFOR UPDATE",
37943833
self::SELECT_LOCK_SHARED => "\nFOR SHARE",
37953834
default => throw new DatabaseException('Query Builder has an invalid SELECT lock mode.'),
3835+
} . $this->compileSelectLockWait();
3836+
}
3837+
3838+
/**
3839+
* Compile the SELECT lock wait behavior.
3840+
*/
3841+
protected function compileSelectLockWait(): string
3842+
{
3843+
return match ($this->QBSelectLockWait) {
3844+
self::SELECT_LOCK_WAIT_NOWAIT => ' NOWAIT',
3845+
self::SELECT_LOCK_WAIT_SKIP_LOCKED => ' SKIP LOCKED',
3846+
null => '',
3847+
default => throw new DatabaseException('Query Builder has an invalid SELECT lock wait behavior.'),
37963848
};
37973849
}
37983850

3851+
/**
3852+
* Ensures SELECT lock wait behavior has a pessimistic lock to modify.
3853+
*/
3854+
protected function assertSelectLockWaitHasLock(): void
3855+
{
3856+
if ($this->QBSelectLock !== null || $this->QBSelectLockWait === null) {
3857+
return;
3858+
}
3859+
3860+
throw new DatabaseException(sprintf(
3861+
'Query Builder does not support %s() without lockForUpdate() or sharedLock().',
3862+
$this->selectLockWaitMethod(),
3863+
));
3864+
}
3865+
37993866
/**
38003867
* Returns the public method name for the current SELECT lock mode.
38013868
*/
@@ -3808,6 +3875,18 @@ protected function selectLockMethod(): string
38083875
};
38093876
}
38103877

3878+
/**
3879+
* Returns the public method name for the current SELECT lock wait behavior.
3880+
*/
3881+
protected function selectLockWaitMethod(): string
3882+
{
3883+
return match ($this->QBSelectLockWait) {
3884+
self::SELECT_LOCK_WAIT_NOWAIT => 'nowait',
3885+
self::SELECT_LOCK_WAIT_SKIP_LOCKED => 'skipLocked',
3886+
default => 'selectLockWait',
3887+
};
3888+
}
3889+
38113890
/**
38123891
* Checks if the ignore option is supported by
38133892
* the Database Driver for the specific statement.
@@ -4152,6 +4231,7 @@ protected function resetSelect()
41524231
'QBLimit' => false,
41534232
'QBOffset' => false,
41544233
'QBSelectLock' => null,
4234+
'QBSelectLockWait' => null,
41554235
'QBSelectUsesAggregate' => false,
41564236
'QBUnion' => [],
41574237
]);

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: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
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 SHARED_LOCK_HINTS = ['HOLDLOCK', 'ROWLOCK'];
3838

3939
/**
4040
* ORDER BY random keyword
@@ -96,11 +96,27 @@ protected function _fromTables(): string
9696
*/
9797
private function compileTableLockHint(): string
9898
{
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 => '',
99+
$hints = match ($this->QBSelectLock) {
100+
self::SELECT_LOCK_FOR_UPDATE => self::LOCK_FOR_UPDATE_HINTS,
101+
self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINTS,
102+
default => [],
103103
};
104+
105+
if ($hints === []) {
106+
return '';
107+
}
108+
109+
if ($this->QBSelectLockWait === self::SELECT_LOCK_WAIT_NOWAIT) {
110+
$hints[] = 'NOWAIT';
111+
} elseif (
112+
$this->QBSelectLock === self::SELECT_LOCK_FOR_UPDATE
113+
&& $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED
114+
) {
115+
$hints[] = 'READCOMMITTEDLOCK';
116+
$hints[] = 'READPAST';
117+
}
118+
119+
return ' WITH (' . implode(', ', $hints) . ')';
104120
}
105121

106122
/**
@@ -639,7 +655,14 @@ protected function compileSelect($selectOverride = false): string
639655
protected function compileSelectLock(): string
640656
{
641657
if ($this->QBSelectLock === null) {
642-
return '';
658+
return parent::compileSelectLock();
659+
}
660+
661+
if (
662+
$this->QBSelectLock === self::SELECT_LOCK_SHARED
663+
&& $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED
664+
) {
665+
throw new DatabaseException('SQLSRV does not support sharedLock() with skipLocked().');
643666
}
644667

645668
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)