diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index a583f7045f02..63b0d9fe92ec 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -110,6 +110,11 @@ class BaseBuilder */ protected $QBOffset = false; + /** + * QB FOR UPDATE flag + */ + protected bool $QBLockForUpdate = false; + /** * QB ORDER BY data * @@ -1620,6 +1625,16 @@ public function limit(?int $value = null, ?int $offset = 0) return $this; } + /** + * Locks the selected rows for update. + */ + public function lockForUpdate(): static + { + $this->QBLockForUpdate = true; + + return $this; + } + /** * Sets the OFFSET value * @@ -1801,20 +1816,26 @@ public function countAllResults(bool $reset = true) } // We cannot use a LIMIT when getting the single row COUNT(*) result - $limit = $this->QBLimit; + $limit = $this->QBLimit; + $lockForUpdate = $this->QBLockForUpdate; - $this->QBLimit = false; + $this->QBLimit = false; + $this->QBLockForUpdate = false; - if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { - // We need to backup the original SELECT in case DBPrefix is used - $select = $this->QBSelect; - $sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results"; + try { + if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { + // We need to backup the original SELECT in case DBPrefix is used + $select = $this->QBSelect; + $sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results"; - // Restore SELECT part - $this->QBSelect = $select; - unset($select); - } else { - $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); + // Restore SELECT part + $this->QBSelect = $select; + unset($select); + } else { + $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); + } + } finally { + $this->QBLockForUpdate = $lockForUpdate; } if ($this->testMode) { @@ -3223,9 +3244,19 @@ protected function compileSelect($selectOverride = false): string $sql = $this->_limit($sql . "\n"); } + $sql .= $this->compileLockForUpdate(); + return $this->unionInjection($sql); } + /** + * Compile the SELECT lock clause. + */ + protected function compileLockForUpdate(): string + { + return $this->QBLockForUpdate ? "\nFOR UPDATE" : ''; + } + /** * Checks if the ignore option is supported by * the Database Driver for the specific statement. @@ -3533,17 +3564,18 @@ protected function resetRun(array $qbResetItems) protected function resetSelect() { $this->resetRun([ - 'QBSelect' => [], - 'QBJoin' => [], - 'QBWhere' => [], - 'QBGroupBy' => [], - 'QBHaving' => [], - 'QBOrderBy' => [], - 'QBNoEscape' => [], - 'QBDistinct' => false, - 'QBLimit' => false, - 'QBOffset' => false, - 'QBUnion' => [], + 'QBSelect' => [], + 'QBJoin' => [], + 'QBWhere' => [], + 'QBGroupBy' => [], + 'QBHaving' => [], + 'QBOrderBy' => [], + 'QBNoEscape' => [], + 'QBDistinct' => false, + 'QBLimit' => false, + 'QBOffset' => false, + 'QBLockForUpdate' => false, + 'QBUnion' => [], ]); if ($this->db instanceof BaseConnection) { diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 2e62b3121cbd..7fc20a57edd7 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -213,6 +213,18 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string return $sql . ' OFFSET ' . $offset . ' ROWS FETCH NEXT ' . $this->QBLimit . ' ROWS ONLY'; } + /** + * Compile the SELECT lock clause. + */ + protected function compileLockForUpdate(): string + { + if ($this->QBLockForUpdate && ($this->QBLimit !== false || $this->QBOffset)) { + throw new DatabaseException('OCI8 does not support lockForUpdate() with limit() or offset().'); + } + + return parent::compileLockForUpdate(); + } + /** * Generates a platform-specific batch update string from the supplied data */ diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index ffda2ba6cb08..6677bf32c6ea 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -31,6 +31,8 @@ */ class Builder extends BaseBuilder { + private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)'; + /** * ORDER BY random keyword * @@ -74,7 +76,17 @@ protected function _fromTables(): string $from = []; foreach ($this->QBFrom as $value) { - $from[] = str_starts_with($value, '(SELECT') ? $value : $this->getFullName($value); + if (str_starts_with($value, '(SELECT')) { + if ($this->QBLockForUpdate) { + throw new DatabaseException('SQLSRV does not support lockForUpdate() on subqueries.'); + } + + $from[] = $value; + + continue; + } + + $from[] = $this->getFullName($value) . ($this->QBLockForUpdate ? self::LOCK_FOR_UPDATE_HINT : ''); } return implode(', ', $from); @@ -675,9 +687,23 @@ protected function compileSelect($selectOverride = false): string $sql = $this->_limit($sql . "\n"); } + $sql .= $this->compileLockForUpdate(); + return $this->unionInjection($sql); } + /** + * Compile the SELECT lock clause. + */ + protected function compileLockForUpdate(): string + { + if ($this->QBLockForUpdate && $this->QBFrom === []) { + throw new DatabaseException('SQLSRV does not support lockForUpdate() without a FROM table.'); + } + + return ''; + } + /** * Compiles the select statement based on the other functions called * and runs the query diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index 4f8dff97a0ea..700eef9e877d 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -55,6 +55,18 @@ class Builder extends BaseBuilder 'insert' => 'OR IGNORE', ]; + /** + * Compile the SELECT lock clause. + */ + protected function compileLockForUpdate(): string + { + if ($this->QBLockForUpdate) { + throw new DatabaseException('SQLite3 does not support lockForUpdate().'); + } + + return ''; + } + /** * Replace statement * diff --git a/tests/system/Database/Builder/CountTest.php b/tests/system/Database/Builder/CountTest.php index 8d129efb5c31..c97359f53d67 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\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\Group; @@ -55,6 +56,34 @@ public function testCountAllResults(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); } + public function testCountAllResultsDoesNotUseLockForUpdate(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false); + + $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + } + + public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false); + + $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "test"."dbo"."jobs" WHERE "id" > :id:'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + } + public function testCountAllResultsWithGroupBy(): void { $builder = new BaseBuilder('jobs', $this->db); diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php index 0b377e408730..ca9f4cecd254 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -14,8 +14,11 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\OCI8\Builder as OCI8Builder; use CodeIgniter\Database\RawSql; +use CodeIgniter\Database\SQLite3\Builder as SQLite3Builder; use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -381,6 +384,145 @@ public function testSimpleSelectWithSQLSRV(): void $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); } + public function testLockForUpdate(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->where('id', 1)->orderBy('id', 'ASC')->limit(1)->lockForUpdate(); + + $expected = 'SELECT * FROM "users" WHERE "id" = 1 ORDER BY "id" ASC LIMIT 1 FOR UPDATE'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testLockForUpdatePersistsWhenSelectIsNotReset(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->lockForUpdate(); + + $expected = 'SELECT * FROM "users" FOR UPDATE'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect(false))); + } + + public function testLockForUpdateResetsWithSelect(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->lockForUpdate(); + + $this->assertSame('SELECT * FROM "users" FOR UPDATE', str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame('SELECT * FROM "users"', str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testLockForUpdateWithOCI8(): void + { + $builder = new OCI8Builder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->lockForUpdate()->getCompiledSelect())); + } + + public function testLockForUpdateThrowsExceptionWithOCI8Limit(): void + { + $builder = new OCI8Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support lockForUpdate() with limit() or offset().'); + + $builder->limit(1)->lockForUpdate()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionOnSQLite3(): void + { + $builder = new SQLite3Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLite3 does not support lockForUpdate().'); + + $builder->lockForUpdate()->getCompiledSelect(); + } + + public function testLockForUpdateWithSQLSRV(): 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)'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->lockForUpdate()->getCompiledSelect())); + } + + public function testLockForUpdateWithSQLSRVAlias(): 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 (UPDLOCK, ROWLOCK)'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->lockForUpdate()->getCompiledSelect())); + } + + public function testLockForUpdateWithSQLSRVLimit(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $builder->where('id', 1)->orderBy('id', 'ASC')->limit(1)->lockForUpdate(); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (UPDLOCK, ROWLOCK) WHERE "id" = 1 ORDER BY "id" ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY'; + + $this->assertSame($expected, trim(str_replace("\n", ' ', $builder->getCompiledSelect()))); + } + + public function testLockForUpdateWithSQLSRVJoin(): 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')->lockForUpdate(); + + $expected = 'SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id"'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testLockForUpdateThrowsExceptionOnSQLSRVWithoutFromTable(): 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 lockForUpdate() without a FROM table.'); + + $builder->lockForUpdate()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionOnSQLSRVSubquery(): 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 lockForUpdate() on subqueries.'); + + $builder->lockForUpdate()->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 29e3aba1acfb..69c8028c4f4b 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -210,6 +210,7 @@ Query Builder - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. - Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations. +- Added ``lockForUpdate()`` to add pessimistic write locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-lock-for-update`. Forge ----- diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 8b6787947eda..aff92dca8dd4 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -753,6 +753,43 @@ 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 +******************** + +Lock for Update +=============== + +$builder->lockForUpdate() +------------------------- + +.. versionadded:: 4.8.0 + +Adds a pessimistic write lock to a ``SELECT`` query. This is useful when a row +must be read and then updated safely while other transactions are prevented +from modifying it first. + +.. literalinclude:: query_builder/124.php + +Use this method inside a database transaction. 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``. See the +following notes for OCI8 and SQLSRV driver-specific behavior. + +.. note:: SQLSRV uses SQL Server table hints instead of a trailing ``FOR UPDATE`` + 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 ``lockForUpdate()`` without a ``FROM`` table or on subqueries. + +.. note:: OCI8 does not support ``lockForUpdate()`` together with ``limit()`` or + ``offset()`` because Oracle does not allow ``FOR UPDATE`` with row + limiting. + .. _query-builder-union: ************* @@ -1429,6 +1466,13 @@ Class Reference Same as ``get()``, but also allows the WHERE to be added directly. + .. php:method:: lockForUpdate() + + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Adds a pessimistic write lock to a ``SELECT`` query. See :ref:`query-builder-lock-for-update`. + .. 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/124.php b/user_guide_src/source/database/query_builder/124.php new file mode 100644 index 000000000000..a9d2003c7051 --- /dev/null +++ b/user_guide_src/source/database/query_builder/124.php @@ -0,0 +1,11 @@ +transaction(static function ($db) use ($accountId): void { + $account = $db->table('accounts') + ->where('id', $accountId) + ->lockForUpdate() + ->get() + ->getRow(); + + // Use $account to update the locked row safely... +});