diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index a583f7045f02..e7a1ec1fedc7 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -110,6 +110,16 @@ class BaseBuilder */ protected $QBOffset = false; + /** + * QB FOR UPDATE flag + */ + protected bool $QBLockForUpdate = false; + + /** + * QB SELECT aggregate helper flag + */ + protected bool $QBSelectUsesAggregate = false; + /** * QB ORDER BY data * @@ -542,8 +552,9 @@ protected function maxMinAvgSum(string $select = '', string $alias = '', string $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias)); - $this->QBSelect[] = $sql; - $this->QBNoEscape[] = null; + $this->QBSelect[] = $sql; + $this->QBNoEscape[] = null; + $this->QBSelectUsesAggregate = true; return $this; } @@ -1620,6 +1631,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 +1822,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 +3250,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->QBUnion !== []) { + throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().'); + } + + return $this->QBLockForUpdate ? "\nFOR UPDATE" : ''; + } + /** * Checks if the ignore option is supported by * the Database Driver for the specific statement. @@ -3533,17 +3574,19 @@ 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, + 'QBSelectUsesAggregate' => false, + 'QBUnion' => [], ]); if ($this->db instanceof BaseConnection) { diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 0bbf4283d056..4b50b49358fc 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -213,6 +213,26 @@ 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) { + return ''; + } + + if ($this->QBLimit !== false || $this->QBOffset) { + throw new DatabaseException('OCI8 does not support lockForUpdate() with limit() or offset().'); + } + + if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) { + throw new DatabaseException('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + } + + return parent::compileLockForUpdate(); + } + /** * Generates a platform-specific batch update string from the supplied data */ diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 8408cb1c729a..32d7de72634f 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -62,6 +62,22 @@ protected function compileIgnore(string $statement) return $sql; } + /** + * Compile the SELECT lock clause. + */ + protected function compileLockForUpdate(): string + { + if (! $this->QBLockForUpdate) { + 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.'); + } + + return parent::compileLockForUpdate(); + } + /** * ORDER BY * diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 38bf8813356e..558f3f9232ee 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -33,6 +33,8 @@ */ class Builder extends BaseBuilder { + private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)'; + /** * ORDER BY random keyword * @@ -76,7 +78,13 @@ 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')) { + $from[] = $value; + + continue; + } + + $from[] = $this->getFullName($value) . ($this->QBLockForUpdate ? self::LOCK_FOR_UPDATE_HINT : ''); } return implode(', ', $from); @@ -677,9 +685,37 @@ 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) { + return ''; + } + + if ($this->QBFrom === []) { + throw new DatabaseException('SQLSRV does not support lockForUpdate() without a FROM table.'); + } + + if ($this->QBUnion !== []) { + throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().'); + } + + foreach ($this->QBFrom as $value) { + if (str_starts_with($value, '(SELECT')) { + throw new DatabaseException('SQLSRV does not support lockForUpdate() on subqueries.'); + } + } + + 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..e854d23e9517 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -14,8 +14,12 @@ 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\Postgre\Builder as PostgreBuilder; 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 +385,227 @@ 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 testLockForUpdateThrowsExceptionWithUnion(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support lockForUpdate() with union() or unionAll().'); + + $builder->union(new BaseBuilder('jobs', $this->db))->lockForUpdate()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionWithSQLSRVUnion(): 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 lockForUpdate() with union() or unionAll().'); + + $builder->union(new SQLSRVBuilder('jobs', $this->db))->lockForUpdate()->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(); + } + + #[DataProvider('provideLockForUpdateUnsupportedSelectClauses')] + public function testLockForUpdateThrowsExceptionWithOCI8SelectClause(string $clause): void + { + $builder = new OCI8Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + + $this->applyLockForUpdateUnsupportedClause($builder, $clause) + ->lockForUpdate() + ->getCompiledSelect(); + } + + public function testLockForUpdateWithPostgre(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE'; + + $this->assertSame($expected, str_replace("\n", ' ', $builder->lockForUpdate()->getCompiledSelect())); + } + + #[DataProvider('provideLockForUpdateUnsupportedSelectClauses')] + public function testLockForUpdateThrowsExceptionWithPostgreSelectClause(string $clause): void + { + $builder = new PostgreBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Postgre does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + + $this->applyLockForUpdateUnsupportedClause($builder, $clause) + ->lockForUpdate() + ->getCompiledSelect(); + } + + /** + * @return iterable> + */ + public static function provideLockForUpdateUnsupportedSelectClauses(): iterable + { + yield 'distinct' => ['distinct']; + + yield 'groupBy' => ['groupBy']; + + yield 'having' => ['having']; + + yield 'aggregate selection' => ['aggregate']; + } + + private function applyLockForUpdateUnsupportedClause(BaseBuilder $builder, string $clause): BaseBuilder + { + return match ($clause) { + 'distinct' => $builder->distinct(), + 'groupBy' => $builder->groupBy('role'), + 'having' => $builder->having('COUNT(id) >', 1, false), + 'aggregate' => $builder->selectCount('id'), + default => throw new DatabaseException('Unsupported clause: ' . $clause), + }; + } + + 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 e9c3f388edae..fed59d10b502 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -214,6 +214,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..0b49530aa138 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -753,6 +753,50 @@ 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``. +``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. + +.. warning:: Postgre does not support ``lockForUpdate()`` with ``distinct()``, + ``groupBy()``, ``having()``, or aggregate helper selections such as + ``selectCount()``. + +.. warning:: 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. + +.. warning:: OCI8 does not support ``lockForUpdate()`` together with + ``limit()``, ``offset()``, ``distinct()``, ``groupBy()``, ``having()``, or + aggregate helper selections such as ``selectCount()``. + .. _query-builder-union: ************* @@ -1429,6 +1473,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... +});