diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 9d504e926663..b266eb34b276 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -33,6 +33,9 @@ class BaseBuilder { use ConditionalTrait; + protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate'; + protected const SELECT_LOCK_SHARED = 'shared'; + /** * Reset DELETE data flag * @@ -112,9 +115,9 @@ class BaseBuilder protected $QBOffset = false; /** - * QB FOR UPDATE flag + * QB SELECT lock mode */ - protected bool $QBLockForUpdate = false; + protected ?string $QBSelectLock = null; /** * QB SELECT aggregate helper flag @@ -1994,7 +1997,17 @@ public function limit(?int $value = null, ?int $offset = 0) */ public function lockForUpdate(): static { - $this->QBLockForUpdate = true; + $this->QBSelectLock = self::SELECT_LOCK_FOR_UPDATE; + + return $this; + } + + /** + * Locks the selected rows in shared mode. + */ + public function sharedLock(): static + { + $this->QBSelectLock = self::SELECT_LOCK_SHARED; return $this; } @@ -2262,18 +2275,18 @@ protected function doExists(bool $reset = true) */ protected function compileExists(): string { - // ORDER BY and FOR UPDATE are unnecessary for checking row existence, + // 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; - $lockForUpdate = $this->QBLockForUpdate; + $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->QBLockForUpdate = false; + $this->QBOrderBy = null; + $this->QBSelectLock = null; if (! $needsSubquery && $this->QBLimit !== 0) { $this->QBLimit = 1; @@ -2291,12 +2304,12 @@ protected function compileExists(): string return $this->compileSelect('SELECT 1'); } finally { - $this->QBOrderBy = $orderBy; - $this->QBLimit = $limit; - $this->QBOffset = $offset; - $this->QBLockForUpdate = $lockForUpdate; - $this->QBSelect = $select; - $this->QBNoEscape = $noEscape; + $this->QBOrderBy = $orderBy; + $this->QBLimit = $limit; + $this->QBOffset = $offset; + $this->QBSelectLock = $selectLock; + $this->QBSelect = $select; + $this->QBNoEscape = $noEscape; } } @@ -2320,11 +2333,11 @@ public function countAllResults(bool $reset = true) } // We cannot use a LIMIT when getting the single row COUNT(*) result - $limit = $this->QBLimit; - $lockForUpdate = $this->QBLockForUpdate; + $limit = $this->QBLimit; + $selectLock = $this->QBSelectLock; - $this->QBLimit = false; - $this->QBLockForUpdate = false; + $this->QBLimit = false; + $this->QBSelectLock = null; try { if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { @@ -2339,7 +2352,7 @@ public function countAllResults(bool $reset = true) $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); } } finally { - $this->QBLockForUpdate = $lockForUpdate; + $this->QBSelectLock = $selectLock; } if ($this->testMode) { @@ -3755,7 +3768,7 @@ protected function compileSelect($selectOverride = false): string $sql = $this->_limit($sql . "\n"); } - $sql .= $this->compileLockForUpdate(); + $sql .= $this->compileSelectLock(); return $this->unionInjection($sql); } @@ -3763,13 +3776,36 @@ protected function compileSelect($selectOverride = false): string /** * Compile the SELECT lock clause. */ - protected function compileLockForUpdate(): string + protected function compileSelectLock(): string { - if ($this->QBLockForUpdate && $this->QBUnion !== []) { - throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().'); + if ($this->QBSelectLock === null) { + return ''; + } + + if ($this->QBUnion !== []) { + throw new DatabaseException(sprintf( + 'Query Builder does not support %s() with union() or unionAll().', + $this->selectLockMethod(), + )); } - return $this->QBLockForUpdate ? "\nFOR UPDATE" : ''; + return match ($this->QBSelectLock) { + 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.'), + }; + } + + /** + * Returns the public method name for the current SELECT lock mode. + */ + protected function selectLockMethod(): string + { + return match ($this->QBSelectLock) { + self::SELECT_LOCK_FOR_UPDATE => 'lockForUpdate', + self::SELECT_LOCK_SHARED => 'sharedLock', + default => 'selectLock', + }; } /** @@ -4115,7 +4151,7 @@ protected function resetSelect() 'QBDistinct' => false, 'QBLimit' => false, 'QBOffset' => false, - 'QBLockForUpdate' => false, + 'QBSelectLock' => null, 'QBSelectUsesAggregate' => false, 'QBUnion' => [], ]); diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php index 975bb8b24823..9992ef860926 100644 --- a/system/Database/MySQLi/Builder.php +++ b/system/Database/MySQLi/Builder.php @@ -61,19 +61,26 @@ protected function _fromTables(): string /** * Compile the SELECT lock clause. */ - protected function compileLockForUpdate(): string + protected function compileSelectLock(): string { - if (! $this->QBLockForUpdate) { + if ($this->QBSelectLock === null) { return ''; } foreach ($this->QBFrom as $value) { if (str_starts_with($value, '(SELECT')) { - throw new DatabaseException('MySQLi does not support lockForUpdate() with fromSubquery().'); + throw new DatabaseException(sprintf( + 'MySQLi does not support %s() with fromSubquery().', + $this->selectLockMethod(), + )); } } - return parent::compileLockForUpdate(); + if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) { + return "\nLOCK IN SHARE MODE"; + } + + return parent::compileSelectLock(); } /** diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 54eafe6098de..ec2b20b1f97d 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -216,12 +216,16 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string /** * Compile the SELECT lock clause. */ - protected function compileLockForUpdate(): string + protected function compileSelectLock(): string { - if (! $this->QBLockForUpdate) { + if ($this->QBSelectLock === null) { return ''; } + if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) { + throw new DatabaseException('OCI8 does not support sharedLock().'); + } + if ($this->QBLimit !== false || $this->QBOffset) { throw new DatabaseException('OCI8 does not support lockForUpdate() with limit() or offset().'); } @@ -230,7 +234,7 @@ protected function compileLockForUpdate(): string throw new DatabaseException('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); } - return parent::compileLockForUpdate(); + return parent::compileSelectLock(); } /** diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 32d7de72634f..7e6e4a9ab7b4 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -65,17 +65,20 @@ protected function compileIgnore(string $statement) /** * Compile the SELECT lock clause. */ - protected function compileLockForUpdate(): string + protected function compileSelectLock(): string { - if (! $this->QBLockForUpdate) { + if ($this->QBSelectLock === null) { return ''; } if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) { - throw new DatabaseException('Postgre does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + throw new DatabaseException(sprintf( + 'Postgre does not support %s() with distinct(), groupBy(), having(), or aggregate helper selections.', + $this->selectLockMethod(), + )); } - return parent::compileLockForUpdate(); + return parent::compileSelectLock(); } /** diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 823d9bac2b63..cc55d87a0485 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -34,6 +34,7 @@ class Builder extends BaseBuilder { private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)'; + private const SHARED_LOCK_HINT = ' WITH (HOLDLOCK, ROWLOCK)'; /** * ORDER BY random keyword @@ -84,12 +85,24 @@ protected function _fromTables(): string continue; } - $from[] = $this->getFullName($value) . ($this->QBLockForUpdate ? self::LOCK_FOR_UPDATE_HINT : ''); + $from[] = $this->getFullName($value) . $this->compileTableLockHint(); } return implode(', ', $from); } + /** + * Compile the SQL Server table hint for the current SELECT lock mode. + */ + 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 => '', + }; + } + /** * Generates a platform-specific truncate string from the supplied data * @@ -615,7 +628,7 @@ protected function compileSelect($selectOverride = false): string $sql = $this->_limit($sql . "\n"); } - $sql .= $this->compileLockForUpdate(); + $sql .= $this->compileSelectLock(); return $this->unionInjection($sql); } @@ -623,23 +636,32 @@ protected function compileSelect($selectOverride = false): string /** * Compile the SELECT lock clause. */ - protected function compileLockForUpdate(): string + protected function compileSelectLock(): string { - if (! $this->QBLockForUpdate) { + if ($this->QBSelectLock === null) { return ''; } if ($this->QBFrom === []) { - throw new DatabaseException('SQLSRV does not support lockForUpdate() without a FROM table.'); + throw new DatabaseException(sprintf( + 'SQLSRV does not support %s() without a FROM table.', + $this->selectLockMethod(), + )); } if ($this->QBUnion !== []) { - throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().'); + throw new DatabaseException(sprintf( + 'Query Builder does not support %s() with union() or unionAll().', + $this->selectLockMethod(), + )); } foreach ($this->QBFrom as $value) { if (str_starts_with($value, '(SELECT')) { - throw new DatabaseException('SQLSRV does not support lockForUpdate() on subqueries.'); + throw new DatabaseException(sprintf( + 'SQLSRV does not support %s() on subqueries.', + $this->selectLockMethod(), + )); } } diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index a0403f8827d7..cbdbc1f5ba50 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -58,10 +58,13 @@ class Builder extends BaseBuilder /** * Compile the SELECT lock clause. */ - protected function compileLockForUpdate(): string + protected function compileSelectLock(): string { - if ($this->QBLockForUpdate) { - throw new DatabaseException('SQLite3 does not support lockForUpdate().'); + if ($this->QBSelectLock !== null) { + throw new DatabaseException(sprintf( + 'SQLite3 does not support %s().', + $this->selectLockMethod(), + )); } return ''; diff --git a/tests/system/Database/Builder/CountTest.php b/tests/system/Database/Builder/CountTest.php index 500cd5a9e83f..8488cd8b03bb 100644 --- a/tests/system/Database/Builder/CountTest.php +++ b/tests/system/Database/Builder/CountTest.php @@ -69,6 +69,19 @@ public function testCountAllResultsDoesNotUseLockForUpdate(): void $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', $builder->getCompiledSelect(false)); } + public function testCountAllResultsDoesNotUseSharedLock(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->sharedLock()->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 SHARE', $builder->getCompiledSelect(false)); + } + public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -84,6 +97,21 @@ public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false)); } + public function testCountAllResultsWithSQLSRVDoesNotUseSharedLock(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->sharedLock()->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 (HOLDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false)); + } + public function testCountAllResultsWithGroupBy(): void { $builder = new BaseBuilder('jobs', $this->db); diff --git a/tests/system/Database/Builder/ExistsTest.php b/tests/system/Database/Builder/ExistsTest.php index f73a28c94ae7..8a6b033b6228 100644 --- a/tests/system/Database/Builder/ExistsTest.php +++ b/tests/system/Database/Builder/ExistsTest.php @@ -73,6 +73,22 @@ public function testExistsDoesNotUseOrderByOrLockForUpdate(): void $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR UPDATE', $builder->getCompiledSelect(false)); } + public function testExistsDoesNotUseOrderByOrSharedLock(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->sharedLock() + ->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 SHARE', $builder->getCompiledSelect(false)); + } + public function testExistsWithSQLSRVDoesNotUseOrderByOrLockForUpdate(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -91,6 +107,24 @@ public function testExistsWithSQLSRVDoesNotUseOrderByOrLockForUpdate(): void $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC', $builder->getCompiledSelect(false)); } + public function testExistsWithSQLSRVDoesNotUseOrderByOrSharedLock(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->sharedLock() + ->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 (HOLDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC', $builder->getCompiledSelect(false)); + } + public function testExistsHonorsExistingLimitAndOffset(): void { $builder = new BaseBuilder('jobs', $this->db); diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php index 304a0b4fd414..6054184cee7e 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -419,6 +419,50 @@ public function testLockForUpdateResetsWithSelect(): void $this->assertSameSql('SELECT * FROM "users"', $builder->getCompiledSelect()); } + public function testSharedLock(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->where('id', 1)->orderBy('id', 'ASC')->limit(1)->sharedLock(); + + $expected = 'SELECT * FROM "users" WHERE "id" = 1 ORDER BY "id" ASC LIMIT 1 FOR SHARE'; + + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + + public function testSharedLockPersistsWhenSelectIsNotReset(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->sharedLock(); + + $expected = 'SELECT * FROM "users" FOR SHARE'; + + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + } + + public function testSharedLockResetsWithSelect(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->sharedLock(); + + $this->assertSameSql('SELECT * FROM "users" FOR SHARE', $builder->getCompiledSelect()); + $this->assertSameSql('SELECT * FROM "users"', $builder->getCompiledSelect()); + } + + public function testSelectLockLastCallWins(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE', $builder->sharedLock()->lockForUpdate()->getCompiledSelect()); + + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR SHARE', $builder->lockForUpdate()->sharedLock()->getCompiledSelect()); + } + public function testLockForUpdateThrowsExceptionWithUnion(): void { $builder = new BaseBuilder('users', $this->db); @@ -429,6 +473,16 @@ public function testLockForUpdateThrowsExceptionWithUnion(): void $builder->union(new BaseBuilder('jobs', $this->db))->lockForUpdate()->getCompiledSelect(); } + public function testSharedLockThrowsExceptionWithUnion(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support sharedLock() with union() or unionAll().'); + + $builder->union(new BaseBuilder('jobs', $this->db))->sharedLock()->getCompiledSelect(); + } + public function testLockForUpdateThrowsExceptionWithSQLSRVUnion(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -441,6 +495,18 @@ public function testLockForUpdateThrowsExceptionWithSQLSRVUnion(): void $builder->union(new SQLSRVBuilder('jobs', $this->db))->lockForUpdate()->getCompiledSelect(); } + public function testSharedLockThrowsExceptionWithSQLSRVUnion(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support sharedLock() with union() or unionAll().'); + + $builder->union(new SQLSRVBuilder('jobs', $this->db))->sharedLock()->getCompiledSelect(); + } + public function testLockForUpdateThrowsExceptionOnMySQLiSubquery(): void { $this->db = new MockConnection(['DBDriver' => 'MySQLi']); @@ -456,6 +522,32 @@ public function testLockForUpdateThrowsExceptionOnMySQLiSubquery(): void $builder->lockForUpdate()->getCompiledSelect(); } + public function testSharedLockThrowsExceptionOnMySQLiSubquery(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $subquery = new MySQLiBuilder('users', $this->db); + $builder = new MySQLiBuilder('jobs', $this->db); + + $builder->fromSubquery($subquery, 'users_1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('MySQLi does not support sharedLock() with fromSubquery().'); + + $builder->sharedLock()->getCompiledSelect(); + } + + public function testSharedLockWithMySQLi(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $builder = new MySQLiBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" LOCK IN SHARE MODE'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + public function testLockForUpdateWithOCI8(): void { $builder = new OCI8Builder('users', $this->db); @@ -465,6 +557,16 @@ public function testLockForUpdateWithOCI8(): void $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); } + public function testSharedLockThrowsExceptionOnOCI8(): void + { + $builder = new OCI8Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support sharedLock().'); + + $builder->sharedLock()->getCompiledSelect(); + } + public function testLockForUpdateThrowsExceptionWithOCI8Limit(): void { $builder = new OCI8Builder('users', $this->db); @@ -475,7 +577,7 @@ public function testLockForUpdateThrowsExceptionWithOCI8Limit(): void $builder->limit(1)->lockForUpdate()->getCompiledSelect(); } - #[DataProvider('provideLockForUpdateUnsupportedSelectClauses')] + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] public function testLockForUpdateThrowsExceptionWithOCI8SelectClause(string $clause): void { $builder = new OCI8Builder('users', $this->db); @@ -483,7 +585,7 @@ public function testLockForUpdateThrowsExceptionWithOCI8SelectClause(string $cla $this->expectException(DatabaseException::class); $this->expectExceptionMessage('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); - $this->applyLockForUpdateUnsupportedClause($builder, $clause) + $this->applySelectLockUnsupportedClause($builder, $clause) ->lockForUpdate() ->getCompiledSelect(); } @@ -497,7 +599,16 @@ public function testLockForUpdateWithPostgre(): void $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); } - #[DataProvider('provideLockForUpdateUnsupportedSelectClauses')] + public function testSharedLockWithPostgre(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR SHARE'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] public function testLockForUpdateThrowsExceptionWithPostgreSelectClause(string $clause): void { $builder = new PostgreBuilder('users', $this->db); @@ -505,15 +616,28 @@ public function testLockForUpdateThrowsExceptionWithPostgreSelectClause(string $ $this->expectException(DatabaseException::class); $this->expectExceptionMessage('Postgre does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); - $this->applyLockForUpdateUnsupportedClause($builder, $clause) + $this->applySelectLockUnsupportedClause($builder, $clause) ->lockForUpdate() ->getCompiledSelect(); } + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] + public function testSharedLockThrowsExceptionWithPostgreSelectClause(string $clause): void + { + $builder = new PostgreBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Postgre does not support sharedLock() with distinct(), groupBy(), having(), or aggregate helper selections.'); + + $this->applySelectLockUnsupportedClause($builder, $clause) + ->sharedLock() + ->getCompiledSelect(); + } + /** * @return iterable> */ - public static function provideLockForUpdateUnsupportedSelectClauses(): iterable + public static function provideSelectLockUnsupportedSelectClauses(): iterable { yield 'distinct' => ['distinct']; @@ -524,7 +648,7 @@ public static function provideLockForUpdateUnsupportedSelectClauses(): iterable yield 'aggregate selection' => ['aggregate']; } - private function applyLockForUpdateUnsupportedClause(BaseBuilder $builder, string $clause): BaseBuilder + private function applySelectLockUnsupportedClause(BaseBuilder $builder, string $clause): BaseBuilder { return match ($clause) { 'distinct' => $builder->distinct(), @@ -545,6 +669,16 @@ public function testLockForUpdateThrowsExceptionOnSQLite3(): void $builder->lockForUpdate()->getCompiledSelect(); } + public function testSharedLockThrowsExceptionOnSQLite3(): void + { + $builder = new SQLite3Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLite3 does not support sharedLock().'); + + $builder->sharedLock()->getCompiledSelect(); + } + public function testLockForUpdateWithSQLSRV(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -556,6 +690,17 @@ public function testLockForUpdateWithSQLSRV(): void $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); } + public function testSharedLockWithSQLSRV(): 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)'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + public function testLockForUpdateWithSQLSRVAlias(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -567,6 +712,17 @@ public function testLockForUpdateWithSQLSRVAlias(): void $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); } + public function testSharedLockWithSQLSRVAlias(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users u', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" "u" WITH (HOLDLOCK, ROWLOCK)'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + public function testLockForUpdateWithSQLSRVLimit(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -593,6 +749,19 @@ public function testLockForUpdateWithSQLSRVJoin(): void $this->assertSameSql($expected, $builder->getCompiledSelect()); } + public function testSharedLockWithSQLSRVJoin(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + + $builder->join('users u', 'u.id = jobs.id', 'LEFT')->sharedLock(); + + $expected = 'SELECT * FROM "test"."dbo"."jobs" WITH (HOLDLOCK, ROWLOCK) LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id"'; + + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + public function testLockForUpdateThrowsExceptionOnSQLSRVWithoutFromTable(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -607,6 +776,20 @@ public function testLockForUpdateThrowsExceptionOnSQLSRVWithoutFromTable(): void $builder->lockForUpdate()->getCompiledSelect(); } + public function testSharedLockThrowsExceptionOnSQLSRVWithoutFromTable(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = (new SQLSRVBuilder('users', $this->db)) + ->from([], true) + ->select('1', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support sharedLock() without a FROM table.'); + + $builder->sharedLock()->getCompiledSelect(); + } + public function testLockForUpdateThrowsExceptionOnSQLSRVSubquery(): void { $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); @@ -622,6 +805,21 @@ public function testLockForUpdateThrowsExceptionOnSQLSRVSubquery(): void $builder->lockForUpdate()->getCompiledSelect(); } + public function testSharedLockThrowsExceptionOnSQLSRVSubquery(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $subquery = new SQLSRVBuilder('users', $this->db); + $builder = new SQLSRVBuilder('jobs', $this->db); + + $builder->fromSubquery($subquery, 'users_1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support sharedLock() on subqueries.'); + + $builder->sharedLock()->getCompiledSelect(); + } + public function testSelectSubquery(): void { $builder = new BaseBuilder('users', $this->db); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 25b03cb154ec..8b0863daf847 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 ``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`. - Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 09316d6d9036..513dccbb48cb 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -945,12 +945,53 @@ As is in ``countAllResult()`` method, this method resets any field values that y to ``select()`` as well. If you need to keep them, you can pass ``false`` as the first parameter. -.. _query-builder-lock-for-update: - ******************** Pessimistic Locking ******************** +.. _query-builder-shared-lock: + +Shared Lock +=========== + +$builder->sharedLock() +---------------------- + +.. versionadded:: 4.8.0 + +Adds a pessimistic read lock to a ``SELECT`` query. This is useful when rows +must be read consistently while other transactions are prevented from modifying +them until the current transaction ends. + +.. literalinclude:: query_builder/131.php + +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. + +.. warning:: MySQLi does not support ``sharedLock()`` with ``fromSubquery()`` + because an outer locking read on a derived table does not lock the underlying + rows as users may expect. + +.. warning:: Postgre does not support ``sharedLock()`` with ``distinct()``, + ``groupBy()``, ``having()``, or aggregate helper selections such as + ``selectCount()``. + +.. warning:: SQLSRV uses SQL Server table hints instead of a trailing ``FOR SHARE`` + clause. The hint is applied to table references in the ``FROM`` clause; + joined tables are not hinted. Its exact lock granularity depends on SQL + Server's execution plan and transaction isolation level. SQLSRV does not + support ``sharedLock()`` without a ``FROM`` table or on subqueries. + +.. _query-builder-lock-for-update: + Lock for Update =============== @@ -1702,6 +1743,13 @@ Class Reference Adds a pessimistic write lock to a ``SELECT`` query. See :ref:`query-builder-lock-for-update`. + .. php:method:: sharedLock() + + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Adds a pessimistic read lock to a ``SELECT`` query. See :ref:`query-builder-shared-lock`. + .. 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/131.php b/user_guide_src/source/database/query_builder/131.php new file mode 100644 index 000000000000..11071bd0d02f --- /dev/null +++ b/user_guide_src/source/database/query_builder/131.php @@ -0,0 +1,11 @@ +transaction(static function ($db) use ($accountId): void { + $account = $db->table('accounts') + ->where('id', $accountId) + ->sharedLock() + ->get() + ->getRow(); + + // Use $account while preventing concurrent transactions from modifying it... +});