diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index b266eb34b276..07f2f32342ac 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -33,8 +33,10 @@ class BaseBuilder { use ConditionalTrait; - protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate'; - protected const SELECT_LOCK_SHARED = 'shared'; + protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate'; + protected const SELECT_LOCK_SHARED = 'shared'; + protected const SELECT_LOCK_WAIT_NOWAIT = 'nowait'; + protected const SELECT_LOCK_WAIT_SKIP_LOCKED = 'skipLocked'; /** * Reset DELETE data flag @@ -119,6 +121,11 @@ class BaseBuilder */ protected ?string $QBSelectLock = null; + /** + * QB SELECT lock wait behavior + */ + protected ?string $QBSelectLockWait = null; + /** * QB SELECT aggregate helper flag */ @@ -2012,6 +2019,26 @@ public function sharedLock(): static return $this; } + /** + * Fails immediately when selected rows cannot be locked. + */ + public function nowait(): static + { + $this->QBSelectLockWait = self::SELECT_LOCK_WAIT_NOWAIT; + + return $this; + } + + /** + * Skips selected rows that cannot be locked immediately. + */ + public function skipLocked(): static + { + $this->QBSelectLockWait = self::SELECT_LOCK_WAIT_SKIP_LOCKED; + + return $this; + } + /** * Sets the OFFSET value * @@ -2275,18 +2302,22 @@ protected function doExists(bool $reset = true) */ protected function compileExists(): string { + $this->assertSelectLockWaitHasLock(); + // ORDER BY and SELECT locks are unnecessary for checking row existence, // and can produce invalid or surprising SQL on some drivers. - $orderBy = $this->QBOrderBy; - $limit = $this->QBLimit; - $offset = $this->QBOffset; - $selectLock = $this->QBSelectLock; - $select = $this->QBSelect; - $noEscape = $this->QBNoEscape; - $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false; - - $this->QBOrderBy = null; - $this->QBSelectLock = null; + $orderBy = $this->QBOrderBy; + $limit = $this->QBLimit; + $offset = $this->QBOffset; + $selectLock = $this->QBSelectLock; + $selectLockWait = $this->QBSelectLockWait; + $select = $this->QBSelect; + $noEscape = $this->QBNoEscape; + $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false; + + $this->QBOrderBy = null; + $this->QBSelectLock = null; + $this->QBSelectLockWait = null; if (! $needsSubquery && $this->QBLimit !== 0) { $this->QBLimit = 1; @@ -2304,12 +2335,13 @@ protected function compileExists(): string return $this->compileSelect('SELECT 1'); } finally { - $this->QBOrderBy = $orderBy; - $this->QBLimit = $limit; - $this->QBOffset = $offset; - $this->QBSelectLock = $selectLock; - $this->QBSelect = $select; - $this->QBNoEscape = $noEscape; + $this->QBOrderBy = $orderBy; + $this->QBLimit = $limit; + $this->QBOffset = $offset; + $this->QBSelectLock = $selectLock; + $this->QBSelectLockWait = $selectLockWait; + $this->QBSelect = $select; + $this->QBNoEscape = $noEscape; } } @@ -2321,6 +2353,8 @@ protected function compileExists(): string */ public function countAllResults(bool $reset = true) { + $this->assertSelectLockWaitHasLock(); + // ORDER BY usage is often problematic here (most notably // on Microsoft SQL Server) and ultimately unnecessary // for selecting COUNT(*) ... @@ -2333,11 +2367,13 @@ public function countAllResults(bool $reset = true) } // We cannot use a LIMIT when getting the single row COUNT(*) result - $limit = $this->QBLimit; - $selectLock = $this->QBSelectLock; + $limit = $this->QBLimit; + $selectLock = $this->QBSelectLock; + $selectLockWait = $this->QBSelectLockWait; - $this->QBLimit = false; - $this->QBSelectLock = null; + $this->QBLimit = false; + $this->QBSelectLock = null; + $this->QBSelectLockWait = null; try { if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { @@ -2352,7 +2388,8 @@ public function countAllResults(bool $reset = true) $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); } } finally { - $this->QBSelectLock = $selectLock; + $this->QBSelectLock = $selectLock; + $this->QBSelectLockWait = $selectLockWait; } if ($this->testMode) { @@ -3778,6 +3815,8 @@ protected function compileSelect($selectOverride = false): string */ protected function compileSelectLock(): string { + $this->assertSelectLockWaitHasLock(); + if ($this->QBSelectLock === null) { return ''; } @@ -3793,9 +3832,37 @@ protected function compileSelectLock(): string self::SELECT_LOCK_FOR_UPDATE => "\nFOR UPDATE", self::SELECT_LOCK_SHARED => "\nFOR SHARE", default => throw new DatabaseException('Query Builder has an invalid SELECT lock mode.'), + } . $this->compileSelectLockWait(); + } + + /** + * Compile the SELECT lock wait behavior. + */ + protected function compileSelectLockWait(): string + { + return match ($this->QBSelectLockWait) { + self::SELECT_LOCK_WAIT_NOWAIT => ' NOWAIT', + self::SELECT_LOCK_WAIT_SKIP_LOCKED => ' SKIP LOCKED', + null => '', + default => throw new DatabaseException('Query Builder has an invalid SELECT lock wait behavior.'), }; } + /** + * Ensures SELECT lock wait behavior has a pessimistic lock to modify. + */ + protected function assertSelectLockWaitHasLock(): void + { + if ($this->QBSelectLock !== null || $this->QBSelectLockWait === null) { + return; + } + + throw new DatabaseException(sprintf( + 'Query Builder does not support %s() without lockForUpdate() or sharedLock().', + $this->selectLockWaitMethod(), + )); + } + /** * Returns the public method name for the current SELECT lock mode. */ @@ -3808,6 +3875,18 @@ protected function selectLockMethod(): string }; } + /** + * Returns the public method name for the current SELECT lock wait behavior. + */ + protected function selectLockWaitMethod(): string + { + return match ($this->QBSelectLockWait) { + self::SELECT_LOCK_WAIT_NOWAIT => 'nowait', + self::SELECT_LOCK_WAIT_SKIP_LOCKED => 'skipLocked', + default => 'selectLockWait', + }; + } + /** * Checks if the ignore option is supported by * the Database Driver for the specific statement. @@ -4152,6 +4231,7 @@ protected function resetSelect() 'QBLimit' => false, 'QBOffset' => false, 'QBSelectLock' => null, + 'QBSelectLockWait' => null, 'QBSelectUsesAggregate' => false, 'QBUnion' => [], ]); diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php index 9992ef860926..6affda360370 100644 --- a/system/Database/MySQLi/Builder.php +++ b/system/Database/MySQLi/Builder.php @@ -64,7 +64,7 @@ protected function _fromTables(): string protected function compileSelectLock(): string { if ($this->QBSelectLock === null) { - return ''; + return parent::compileSelectLock(); } foreach ($this->QBFrom as $value) { @@ -77,6 +77,13 @@ protected function compileSelectLock(): string } if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) { + if ($this->QBSelectLockWait !== null) { + throw new DatabaseException(sprintf( + 'MySQLi does not support sharedLock() with %s().', + $this->selectLockWaitMethod(), + )); + } + return "\nLOCK IN SHARE MODE"; } diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index ec2b20b1f97d..0773209e9ab7 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -219,7 +219,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string protected function compileSelectLock(): string { if ($this->QBSelectLock === null) { - return ''; + return parent::compileSelectLock(); } if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) { diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 7e6e4a9ab7b4..5483ffd2eb02 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -68,7 +68,7 @@ protected function compileIgnore(string $statement) protected function compileSelectLock(): string { if ($this->QBSelectLock === null) { - return ''; + return parent::compileSelectLock(); } if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) { diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index cc55d87a0485..5600b3ca39b6 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -33,8 +33,9 @@ */ class Builder extends BaseBuilder { - private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)'; - private const SHARED_LOCK_HINT = ' WITH (HOLDLOCK, ROWLOCK)'; + private const LOCK_FOR_UPDATE_HINTS = ['UPDLOCK', 'ROWLOCK']; + private const LOCK_FOR_UPDATE_SKIP_LOCKED_HINTS = ['UPDLOCK', 'READCOMMITTEDLOCK', 'READPAST']; + private const SHARED_LOCK_HINTS = ['HOLDLOCK', 'ROWLOCK']; /** * ORDER BY random keyword @@ -96,11 +97,23 @@ protected function _fromTables(): string */ private function compileTableLockHint(): string { - return match ($this->QBSelectLock) { - self::SELECT_LOCK_FOR_UPDATE => self::LOCK_FOR_UPDATE_HINT, - self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINT, - default => '', + $hints = match ($this->QBSelectLock) { + self::SELECT_LOCK_FOR_UPDATE => $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED + ? self::LOCK_FOR_UPDATE_SKIP_LOCKED_HINTS + : self::LOCK_FOR_UPDATE_HINTS, + self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINTS, + default => [], }; + + if ($hints === []) { + return ''; + } + + if ($this->QBSelectLockWait === self::SELECT_LOCK_WAIT_NOWAIT) { + $hints[] = 'NOWAIT'; + } + + return ' WITH (' . implode(', ', $hints) . ')'; } /** @@ -639,7 +652,14 @@ protected function compileSelect($selectOverride = false): string protected function compileSelectLock(): string { if ($this->QBSelectLock === null) { - return ''; + return parent::compileSelectLock(); + } + + if ( + $this->QBSelectLock === self::SELECT_LOCK_SHARED + && $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED + ) { + throw new DatabaseException('SQLSRV does not support sharedLock() with skipLocked().'); } if ($this->QBFrom === []) { diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index cbdbc1f5ba50..bbfa3e628e53 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -60,14 +60,14 @@ class Builder extends BaseBuilder */ protected function compileSelectLock(): string { - if ($this->QBSelectLock !== null) { - throw new DatabaseException(sprintf( - 'SQLite3 does not support %s().', - $this->selectLockMethod(), - )); + if ($this->QBSelectLock === null) { + return parent::compileSelectLock(); } - return ''; + throw new DatabaseException(sprintf( + 'SQLite3 does not support %s().', + $this->selectLockMethod(), + )); } /** diff --git a/tests/system/Database/Builder/CountTest.php b/tests/system/Database/Builder/CountTest.php index 8488cd8b03bb..3331d64802a2 100644 --- a/tests/system/Database/Builder/CountTest.php +++ b/tests/system/Database/Builder/CountTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -61,12 +62,22 @@ public function testCountAllResultsDoesNotUseLockForUpdate(): void $builder = new BaseBuilder('jobs', $this->db); $builder->testMode(); - $answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false); + $answer = $builder->where('id >', 3)->lockForUpdate()->skipLocked()->countAllResults(false); $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:'; $this->assertSameSql($expectedSQL, $answer); - $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', $builder->getCompiledSelect(false)); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE SKIP LOCKED', $builder->getCompiledSelect(false)); + } + + public function testCountAllResultsThrowsExceptionWithSelectLockWaitWithoutSelectLock(): void + { + $builder = new BaseBuilder('jobs', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support skipLocked() without lockForUpdate() or sharedLock().'); + + $builder->skipLocked()->countAllResults(); } public function testCountAllResultsDoesNotUseSharedLock(): void @@ -89,12 +100,12 @@ public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void $builder = new SQLSRVBuilder('jobs', $this->db); $builder->testMode(); - $answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false); + $answer = $builder->where('id >', 3)->lockForUpdate()->nowait()->countAllResults(false); $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "test"."dbo"."jobs" WHERE "id" > :id:'; $this->assertSameSql($expectedSQL, $answer); - $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false)); + $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK, NOWAIT) WHERE "id" > 3', $builder->getCompiledSelect(false)); } public function testCountAllResultsWithSQLSRVDoesNotUseSharedLock(): void diff --git a/tests/system/Database/Builder/ExistsTest.php b/tests/system/Database/Builder/ExistsTest.php index 8a6b033b6228..d03f3c2407a4 100644 --- a/tests/system/Database/Builder/ExistsTest.php +++ b/tests/system/Database/Builder/ExistsTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -65,12 +66,23 @@ public function testExistsDoesNotUseOrderByOrLockForUpdate(): void $answer = $builder->where('id >', 3) ->orderBy('id', 'DESC') ->lockForUpdate() + ->nowait() ->exists(false); $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; $this->assertSameSql($expectedSQL, $answer); - $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR UPDATE', $builder->getCompiledSelect(false)); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR UPDATE NOWAIT', $builder->getCompiledSelect(false)); + } + + public function testExistsThrowsExceptionWithSelectLockWaitWithoutSelectLock(): void + { + $builder = new BaseBuilder('jobs', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support nowait() without lockForUpdate() or sharedLock().'); + + $builder->nowait()->exists(); } public function testExistsDoesNotUseOrderByOrSharedLock(): void @@ -99,12 +111,13 @@ public function testExistsWithSQLSRVDoesNotUseOrderByOrLockForUpdate(): void $answer = $builder->where('id >', 3) ->orderBy('id', 'DESC') ->lockForUpdate() + ->skipLocked() ->exists(false); $expectedSQL = 'SELECT 1 FROM "test"."dbo"."jobs" WHERE "id" > :id: ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY '; $this->assertSameSql($expectedSQL, $answer); - $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC', $builder->getCompiledSelect(false)); + $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, READCOMMITTEDLOCK, READPAST) WHERE "id" > 3 ORDER BY "id" DESC', $builder->getCompiledSelect(false)); } public function testExistsWithSQLSRVDoesNotUseOrderByOrSharedLock(): void diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php index 6054184cee7e..2e8c35b32786 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -452,6 +452,87 @@ public function testSharedLockResetsWithSelect(): void $this->assertSameSql('SELECT * FROM "users"', $builder->getCompiledSelect()); } + public function testLockForUpdateWithNowait(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE NOWAIT', $builder->lockForUpdate()->nowait()->getCompiledSelect()); + } + + public function testLockForUpdateWithSkipLocked(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE SKIP LOCKED', $builder->lockForUpdate()->skipLocked()->getCompiledSelect()); + } + + public function testSharedLockWithNowait(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR SHARE NOWAIT', $builder->sharedLock()->nowait()->getCompiledSelect()); + } + + public function testSharedLockWithSkipLocked(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR SHARE SKIP LOCKED', $builder->sharedLock()->skipLocked()->getCompiledSelect()); + } + + public function testSelectLockWaitPersistsWhenSelectIsNotReset(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->lockForUpdate()->skipLocked(); + + $expected = 'SELECT * FROM "users" FOR UPDATE SKIP LOCKED'; + + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + } + + public function testSelectLockWaitResetsWithSelect(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->lockForUpdate()->nowait(); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE NOWAIT', $builder->getCompiledSelect()); + $this->assertSameSql('SELECT * FROM "users"', $builder->getCompiledSelect()); + } + + public function testSelectLockWaitLastCallWins(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE NOWAIT', $builder->lockForUpdate()->skipLocked()->nowait()->getCompiledSelect()); + + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE SKIP LOCKED', $builder->lockForUpdate()->nowait()->skipLocked()->getCompiledSelect()); + } + + public function testNowaitThrowsExceptionWithoutSelectLock(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support nowait() without lockForUpdate() or sharedLock().'); + + $builder->nowait()->getCompiledSelect(); + } + + public function testSkipLockedThrowsExceptionWithoutSelectLock(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support skipLocked() without lockForUpdate() or sharedLock().'); + + $builder->skipLocked()->getCompiledSelect(); + } + public function testSelectLockLastCallWins(): void { $builder = new BaseBuilder('users', $this->db); @@ -548,6 +629,52 @@ public function testSharedLockWithMySQLi(): void $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); } + public function testLockForUpdateWithMySQLiNowait(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $builder = new MySQLiBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE NOWAIT'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->nowait()->getCompiledSelect()); + } + + public function testLockForUpdateWithMySQLiSkipLocked(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $builder = new MySQLiBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE SKIP LOCKED'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->skipLocked()->getCompiledSelect()); + } + + public function testSharedLockWithMySQLiNowaitThrowsException(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $builder = new MySQLiBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('MySQLi does not support sharedLock() with nowait().'); + + $builder->sharedLock()->nowait()->getCompiledSelect(); + } + + public function testSharedLockWithMySQLiSkipLockedThrowsException(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $builder = new MySQLiBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('MySQLi does not support sharedLock() with skipLocked().'); + + $builder->sharedLock()->skipLocked()->getCompiledSelect(); + } + public function testLockForUpdateWithOCI8(): void { $builder = new OCI8Builder('users', $this->db); @@ -557,6 +684,24 @@ public function testLockForUpdateWithOCI8(): void $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); } + public function testLockForUpdateWithOCI8Nowait(): void + { + $builder = new OCI8Builder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE NOWAIT'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->nowait()->getCompiledSelect()); + } + + public function testLockForUpdateWithOCI8SkipLocked(): void + { + $builder = new OCI8Builder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE SKIP LOCKED'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->skipLocked()->getCompiledSelect()); + } + public function testSharedLockThrowsExceptionOnOCI8(): void { $builder = new OCI8Builder('users', $this->db); @@ -608,6 +753,42 @@ public function testSharedLockWithPostgre(): void $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); } + public function testLockForUpdateWithPostgreNowait(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE NOWAIT'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->nowait()->getCompiledSelect()); + } + + public function testLockForUpdateWithPostgreSkipLocked(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE SKIP LOCKED'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->skipLocked()->getCompiledSelect()); + } + + public function testSharedLockWithPostgreNowait(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR SHARE NOWAIT'; + + $this->assertSameSql($expected, $builder->sharedLock()->nowait()->getCompiledSelect()); + } + + public function testSharedLockWithPostgreSkipLocked(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR SHARE SKIP LOCKED'; + + $this->assertSameSql($expected, $builder->sharedLock()->skipLocked()->getCompiledSelect()); + } + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] public function testLockForUpdateThrowsExceptionWithPostgreSelectClause(string $clause): void { @@ -679,6 +860,16 @@ public function testSharedLockThrowsExceptionOnSQLite3(): void $builder->sharedLock()->getCompiledSelect(); } + public function testNowaitThrowsExceptionWithoutSelectLockOnSQLite3(): void + { + $builder = new SQLite3Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support nowait() without lockForUpdate() or sharedLock().'); + + $builder->nowait()->getCompiledSelect(); + } + public function testLockForUpdateWithSQLSRV(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -701,6 +892,51 @@ public function testSharedLockWithSQLSRV(): void $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); } + public function testLockForUpdateWithSQLSRVNowait(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (UPDLOCK, ROWLOCK, NOWAIT)'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->nowait()->getCompiledSelect()); + } + + public function testLockForUpdateWithSQLSRVSkipLocked(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (UPDLOCK, READCOMMITTEDLOCK, READPAST)'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->skipLocked()->getCompiledSelect()); + } + + public function testSharedLockWithSQLSRVNowait(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (HOLDLOCK, ROWLOCK, NOWAIT)'; + + $this->assertSameSql($expected, $builder->sharedLock()->nowait()->getCompiledSelect()); + } + + public function testSharedLockWithSQLSRVSkipLockedThrowsException(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support sharedLock() with skipLocked().'); + + $builder->sharedLock()->skipLocked()->getCompiledSelect(); + } + public function testLockForUpdateWithSQLSRVAlias(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); diff --git a/tests/system/Database/Live/SQLSRV/SelectLockTest.php b/tests/system/Database/Live/SQLSRV/SelectLockTest.php new file mode 100644 index 000000000000..f2cd5cd0fca5 --- /dev/null +++ b/tests/system/Database/Live/SQLSRV/SelectLockTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live\SQLSRV; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class SelectLockTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + parent::setUp(); + + if ($this->db->DBDriver !== 'SQLSRV') { + $this->markTestSkipped('This test is only for SQLSRV.'); + } + } + + public function testLockForUpdateSkipLockedExecutes(): void + { + $row = null; + + $this->db->transBegin(); + + try { + $row = $this->db->table('job') + ->where('name', 'Developer') + ->lockForUpdate() + ->skipLocked() + ->get() + ->getRowArray(); + } finally { + $this->db->transRollback(); + } + + $this->assertIsArray($row); + $this->assertSame('Developer', $row['name']); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e145c14cacbc..8127deb5caef 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -246,6 +246,7 @@ Query Builder - Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`. - Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`. - Added ``likeAny()`` and ``orLikeAny()`` to Query Builder to search one value across multiple fields with grouped ``OR`` ``LIKE`` conditions. See :ref:`query-builder-like-any`. +- Added ``nowait()`` and ``skipLocked()`` lock wait modifiers for pessimistic ``SELECT`` locks on supported drivers. See :ref:`query-builder-nowait` and :ref:`query-builder-skip-locked`. - Added ``sharedLock()`` to add pessimistic read locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-shared-lock`. - Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 513dccbb48cb..b71f662beba1 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -949,6 +949,75 @@ first parameter. Pessimistic Locking ******************** +.. _query-builder-pessimistic-locking-driver-support: + +Driver Support +============== + +The following table summarizes Query Builder support for pessimistic locks and +lock-wait modifiers: + +.. list-table:: + :header-rows: 1 + :widths: 38 15 15 15 18 15 + + * - Query Builder methods + - MySQLi + - Postgre + - OCI8 + - SQLSRV + - SQLite3 + * - ``lockForUpdate()`` + - Yes + - Yes + - Yes + - Yes + - No + * - ``sharedLock()`` + - Yes + - Yes + - No + - Yes + - No + * - ``lockForUpdate()->nowait()`` + - Yes [1]_ + - Yes + - Yes + - Yes + - No + * - ``lockForUpdate()->skipLocked()`` + - Yes [2]_ + - Yes + - Yes + - Approximate [3]_ + - No + * - ``sharedLock()->nowait()`` + - No + - Yes + - No + - Yes + - No + * - ``sharedLock()->skipLocked()`` + - No + - Yes + - No + - No + - No + +.. [1] ``NOWAIT`` requires MySQL 8.0.1+ or MariaDB 10.3.0+. CodeIgniter does + not check the server version before compiling the clause. + +.. [2] ``SKIP LOCKED`` requires MySQL 8.0.1+ or MariaDB 10.6.0+. CodeIgniter + does not check the server version before compiling the clause. + +.. [3] SQLSRV approximates ``skipLocked()`` using SQL Server's + ``READCOMMITTEDLOCK`` and ``READPAST`` table hints. It can still wait on + locks that ``READPAST`` does not skip, such as page-level locks. + +.. note:: ``countAllResults()``, ``exists()``, and ``doesntExist()`` do not apply + pessimistic locks or lock-wait modifiers. Consequently, they report matching + rows rather than rows currently available for locking. + .. _query-builder-shared-lock: Shared Lock @@ -969,12 +1038,12 @@ Use this method inside a database transaction. Without an explicit transaction, the lock is typically released when the ``SELECT`` statement finishes. If the same transaction will update the selected rows, use ``lockForUpdate()`` instead. -This method is supported by the **MySQLi**, **Postgre**, and **SQLSRV** -drivers. Unsupported drivers throw a ``DatabaseException``. ``sharedLock()`` is -not supported with ``union()`` or ``unionAll()``. Some databases restrict which -query shapes can be used with row locking. When CodeIgniter can detect an -unsupported combination, it throws a ``DatabaseException``. See the following -warnings for driver-specific behavior. +See :ref:`query-builder-pessimistic-locking-driver-support` for supported +drivers and lock combinations. Unsupported drivers throw a ``DatabaseException``. +``sharedLock()`` is not supported with ``union()`` or ``unionAll()``. Some +databases restrict which query shapes can be used with row locking. When +CodeIgniter can detect an unsupported combination, it throws a +``DatabaseException``. See the following warnings for driver-specific behavior. .. warning:: MySQLi does not support ``sharedLock()`` with ``fromSubquery()`` because an outer locking read on a derived table does not lock the underlying @@ -1011,10 +1080,10 @@ the lock is typically released when the ``SELECT`` statement finishes. The exact locking behavior is determined by the database server and transaction isolation level. -This method is supported by the **MySQLi**, **Postgre**, **OCI8**, and -**SQLSRV** drivers. Unsupported drivers throw a ``DatabaseException``. -``lockForUpdate()`` is not supported with ``union()`` or ``unionAll()``. -Some databases restrict which query shapes can be used with row locking. When +See :ref:`query-builder-pessimistic-locking-driver-support` for supported +drivers and lock combinations. Unsupported drivers throw a ``DatabaseException``. +``lockForUpdate()`` is not supported with ``union()`` or ``unionAll()``. Some +databases restrict which query shapes can be used with row locking. When CodeIgniter can detect an unsupported combination, it throws a ``DatabaseException``. See the following warnings for driver-specific behavior. @@ -1036,6 +1105,61 @@ CodeIgniter can detect an unsupported combination, it throws a ``limit()``, ``offset()``, ``distinct()``, ``groupBy()``, ``having()``, or aggregate helper selections such as ``selectCount()``. +.. _query-builder-skip-locked: + +Skip Locked +=========== + +$builder->skipLocked() +---------------------- + +.. versionadded:: 4.8.0 + +Adds a ``SKIP LOCKED`` style modifier to a pessimistic ``SELECT`` lock. This is +useful when multiple workers claim rows from the same queue-like table and each +worker should skip rows already locked by another transaction. + +.. literalinclude:: query_builder/132.php + +Use this method with ``lockForUpdate()`` or ``sharedLock()``. Calling +``skipLocked()`` without a pessimistic lock throws a ``DatabaseException``. + +``skipLocked()`` can return an incomplete view of matching rows because locked +rows are skipped instead of waited on. This is usually what queue workers want, +but it is not suitable for general-purpose reads that must see every matching +row. + +See :ref:`query-builder-pessimistic-locking-driver-support` for supported +drivers and lock combinations. Unsupported combinations throw a +``DatabaseException``. + +.. _query-builder-nowait: + +No Wait +======== + +$builder->nowait() +------------------ + +.. versionadded:: 4.8.0 + +Adds a ``NOWAIT`` style modifier to a pessimistic ``SELECT`` lock. This is +useful when the query should fail immediately instead of waiting for locked rows +to become available. + +.. literalinclude:: query_builder/133.php + +Use this method with ``lockForUpdate()`` or ``sharedLock()``. Calling +``nowait()`` without a pessimistic lock throws a ``DatabaseException``. + +When a row cannot be locked immediately, the database server returns an error +and CodeIgniter throws a database exception according to the current database +debug and transaction settings. + +See :ref:`query-builder-pessimistic-locking-driver-support` for supported +drivers and lock combinations. Unsupported combinations throw a +``DatabaseException``. + .. _query-builder-union: ************* @@ -1743,6 +1867,13 @@ Class Reference Adds a pessimistic write lock to a ``SELECT`` query. See :ref:`query-builder-lock-for-update`. + .. php:method:: nowait() + + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Fails immediately when selected rows cannot be locked. See :ref:`query-builder-nowait`. + .. php:method:: sharedLock() :returns: ``BaseBuilder`` instance (method chaining) @@ -1750,6 +1881,13 @@ Class Reference Adds a pessimistic read lock to a ``SELECT`` query. See :ref:`query-builder-shared-lock`. + .. php:method:: skipLocked() + + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Skips selected rows that cannot be locked immediately. See :ref:`query-builder-skip-locked`. + .. php:method:: select([$select = '*'[, $escape = null]]) :param array|RawSql|string $select: The SELECT portion of a query diff --git a/user_guide_src/source/database/query_builder/132.php b/user_guide_src/source/database/query_builder/132.php new file mode 100644 index 000000000000..a195392de35a --- /dev/null +++ b/user_guide_src/source/database/query_builder/132.php @@ -0,0 +1,14 @@ +transaction(static function ($db): void { + $jobs = $db->table('jobs') + ->where('status', 'pending') + ->orderBy('id', 'ASC') + ->limit(10) + ->lockForUpdate() + ->skipLocked() + ->get() + ->getResultArray(); + + // Mark or update the claimed jobs before committing the transaction... +}); diff --git a/user_guide_src/source/database/query_builder/133.php b/user_guide_src/source/database/query_builder/133.php new file mode 100644 index 000000000000..f3d7a6b84ec7 --- /dev/null +++ b/user_guide_src/source/database/query_builder/133.php @@ -0,0 +1,12 @@ +transaction(static function ($db) use ($accountId): void { + $account = $db->table('accounts') + ->where('id', $accountId) + ->lockForUpdate() + ->nowait() + ->get() + ->getRow(); + + // Use $account while preventing concurrent transactions from modifying it... +});