From a9bc5351b9a141508b3587e681ae53b620de54ea Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Thu, 7 May 2026 10:46:33 +0200 Subject: [PATCH 1/3] feat(database): add query builder lockForUpdate Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 78 +++++++--- system/Database/OCI8/Builder.php | 12 ++ system/Database/SQLSRV/Builder.php | 28 +++- system/Database/SQLite3/Builder.php | 12 ++ tests/system/Database/Builder/CountTest.php | 29 ++++ tests/system/Database/Builder/SelectTest.php | 142 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/database/query_builder.rst | 39 +++++ .../source/database/query_builder/124.php | 11 ++ 9 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 user_guide_src/source/database/query_builder/124.php diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index a583f7045f02..3e55cb6a450b 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -110,6 +110,13 @@ class BaseBuilder */ protected $QBOffset = false; + /** + * QB FOR UPDATE flag + * + * @var bool + */ + protected $QBLockForUpdate = false; + /** * QB ORDER BY data * @@ -1620,6 +1627,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 +1818,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 +3246,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 +3566,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..250ac2a234c0 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -753,6 +753,38 @@ 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 +******************** + +$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``. + +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. + +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 +1461,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... +}); From ab652c5f977856b0f44d0997542382d9c1dd0e27 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Thu, 7 May 2026 10:54:59 +0200 Subject: [PATCH 2/3] docs(database): fix query builder lockForUpdate heading level Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- user_guide_src/source/database/query_builder.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 250ac2a234c0..3dc5bf46cce1 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -759,6 +759,9 @@ first parameter. Pessimistic Locking ******************** +Lock for Update +=============== + $builder->lockForUpdate() ------------------------- From b7737bebc7d136e90f6ef237356330d4b93b8221 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Thu, 7 May 2026 14:25:55 +0200 Subject: [PATCH 3/3] docs(database): clarify lockForUpdate driver notes Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 4 +--- .../source/database/query_builder.rst | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 3e55cb6a450b..63b0d9fe92ec 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -112,10 +112,8 @@ class BaseBuilder /** * QB FOR UPDATE flag - * - * @var bool */ - protected $QBLockForUpdate = false; + protected bool $QBLockForUpdate = false; /** * QB ORDER BY data diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 3dc5bf46cce1..aff92dca8dd4 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -777,16 +777,18 @@ 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``. - -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. - -OCI8 does not support ``lockForUpdate()`` together with ``limit()`` or -``offset()`` because Oracle does not allow ``FOR UPDATE`` with row limiting. +**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: