From 1cfa7f8856c7fd1e88c0c908b0f0a0b4296cbb02 Mon Sep 17 00:00:00 2001 From: bbldn05 Date: Mon, 2 Mar 2026 00:41:21 +0300 Subject: [PATCH] feat: Added 'FOR UPDATE SKIP LOCKED' and 'FOR UPDATE NOWAIT' to SelectQuery --- src/Driver/Compiler.php | 44 ++++++- src/Driver/CompilerCache.php | 11 +- src/Driver/MySQL/MySQLCompiler.php | 38 ++++++ src/Driver/SQLServer/SQLServerCompiler.php | 42 ++++++- src/Driver/SQLite/SQLiteCompiler.php | 2 +- src/Query/Enum/LockBehavior.php | 44 +++++++ src/Query/Enum/LockMode.php | 56 +++++++++ src/Query/SelectQuery.php | 23 +++- .../Driver/Common/Query/SelectQueryTest.php | 90 +++++++++++++- .../Driver/MySQL/Query/SelectQueryTest.php | 27 +++++ .../SQLServer/Query/SelectQueryTest.php | 112 ++++++++++++++++-- .../Driver/SQLite/Query/SelectQueryTest.php | 112 ++++++++++++++++-- .../Unit/Query/Tokens/SelectQueryTest.php | 8 +- 13 files changed, 570 insertions(+), 39 deletions(-) create mode 100644 src/Query/Enum/LockBehavior.php create mode 100644 src/Query/Enum/LockMode.php diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index 93ba54a9..77ac0df3 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -11,6 +11,8 @@ namespace Cycle\Database\Driver; +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Exception\CompilerException; use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; @@ -197,7 +199,7 @@ protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens $this->optional("\n", $this->excepts($params, $q, $tokens['except'])), $this->optional("\nORDER BY", $this->orderBy($params, $q, $tokens['orderBy'])), $this->optional("\n", $this->limit($params, $q, $tokens['limit'], $tokens['offset'])), - $this->optional(' ', $tokens['forUpdate'] ? 'FOR UPDATE' : ''), + $this->optional(' ', $this->forUpdate($tokens['forUpdate'])), ); } @@ -611,6 +613,46 @@ protected function compileJsonOrderBy(string $path): string|FragmentInterface return $path; } + /** + * @param array{mode: LockMode, behavior: LockBehavior}|null $forUpdate + */ + protected function forUpdate(?array $forUpdate): string + { + if ($forUpdate !== null) { + $arguments = []; + + switch ($forUpdate['mode']) { + case LockMode::Update: + $arguments[] = 'UPDATE'; + break; + case LockMode::Share: + $arguments[] = 'SHARE'; + break; + case LockMode::NoKeyUpdate: + $arguments[] = 'NO KEY UPDATE'; + break; + case LockMode::KeyShare: + $arguments[] = 'KEY SHARE'; + break; + } + + switch ($forUpdate['behavior']) { + case LockBehavior::Wait: + break; + case LockBehavior::NoWait: + $arguments[] = 'NOWAIT'; + break; + case LockBehavior::SkipLocked: + $arguments[] = 'SKIP LOCKED'; + break; + } + + return \sprintf('FOR %s', \implode(' ', $arguments)); + } + + return ''; + } + private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string { $operator = $in ? 'IN' : 'NOT IN'; diff --git a/src/Driver/CompilerCache.php b/src/Driver/CompilerCache.php index 7aecba87..86a9ac4e 100644 --- a/src/Driver/CompilerCache.php +++ b/src/Driver/CompilerCache.php @@ -151,11 +151,18 @@ protected function hashInsertQuery(QueryParameters $params, array $tokens): stri */ protected function hashSelectQuery(QueryParameters $params, array $tokens): string { + $forUpdate = $tokens['forUpdate']; + if ($forUpdate !== null) { + $forUpdate = $forUpdate['mode']->name . '_' . $forUpdate['behavior']->name; + } else { + $forUpdate = ''; + } + // stable part of hash if (\is_array($tokens['distinct']) && isset($tokens['distinct']['on'])) { - $hash = 's_' . $tokens['forUpdate'] . '_on_' . $tokens['distinct']['on']; + $hash = 's_' . $forUpdate . '_on_' . $tokens['distinct']['on']; } else { - $hash = 's_' . $tokens['forUpdate'] . '_' . $tokens['distinct']; + $hash = 's_' . $forUpdate . '_' . $tokens['distinct']; } foreach ($tokens['from'] as $table) { diff --git a/src/Driver/MySQL/MySQLCompiler.php b/src/Driver/MySQL/MySQLCompiler.php index 4789d9f4..95c4e627 100644 --- a/src/Driver/MySQL/MySQLCompiler.php +++ b/src/Driver/MySQL/MySQLCompiler.php @@ -11,6 +11,8 @@ namespace Cycle\Database\Driver\MySQL; +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Driver\CachingCompilerInterface; use Cycle\Database\Driver\Compiler; use Cycle\Database\Driver\MySQL\Injection\CompileJson; @@ -69,4 +71,40 @@ protected function compileJsonOrderBy(string $path): FragmentInterface { return new CompileJson($path); } + + /** + * @param array{mode: LockMode, behavior: LockBehavior}|null $forUpdate + */ + protected function forUpdate(?array $forUpdate): string + { + if ($forUpdate !== null) { + $arguments = []; + + switch ($forUpdate['mode']) { + case LockMode::Share: + case LockMode::KeyShare: + $arguments[] = 'SHARE'; + break; + case LockMode::Update: + case LockMode::NoKeyUpdate: + $arguments[] = 'UPDATE'; + break; + } + + switch ($forUpdate['behavior']) { + case LockBehavior::Wait: + break; + case LockBehavior::NoWait: + $arguments[] = 'NOWAIT'; + break; + case LockBehavior::SkipLocked: + $arguments[] = 'SKIP LOCKED'; + break; + } + + return \sprintf('FOR %s', \implode(' ', $arguments)); + } + + return ''; + } } diff --git a/src/Driver/SQLServer/SQLServerCompiler.php b/src/Driver/SQLServer/SQLServerCompiler.php index c7819b63..1dfed6ee 100644 --- a/src/Driver/SQLServer/SQLServerCompiler.php +++ b/src/Driver/SQLServer/SQLServerCompiler.php @@ -13,6 +13,8 @@ use Cycle\Database\Driver\Compiler; use Cycle\Database\Driver\Quoter; +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Driver\SQLServer\Injection\CompileJson; use Cycle\Database\Injection\Fragment; use Cycle\Database\Injection\FragmentInterface; @@ -164,6 +166,42 @@ protected function compileJsonOrderBy(string $path): FragmentInterface return new CompileJson($path); } + /** + * @param array{mode: LockMode, behavior: LockBehavior}|null $forUpdate + */ + protected function forUpdate(?array $forUpdate): string + { + if ($forUpdate !== null) { + $arguments = []; + + switch ($forUpdate['mode']) { + case LockMode::Share: + case LockMode::KeyShare: + $arguments[] = 'HOLDLOCK'; + break; + case LockMode::Update: + case LockMode::NoKeyUpdate: + $arguments[] = 'UPDLOCK'; + break; + } + + switch ($forUpdate['behavior']) { + case LockBehavior::Wait: + break; + case LockBehavior::NoWait: + $arguments[] = 'NOWAIT'; + break; + case LockBehavior::SkipLocked: + $arguments[] = 'READPAST'; + break; + } + + return \sprintf('WITH(%s)', \implode(',', $arguments)); + } + + return ''; + } + private function baseSelect(QueryParameters $params, Quoter $q, array $tokens): string { // This statement(s) parts should be processed first to define set of table and column aliases @@ -175,12 +213,14 @@ private function baseSelect(QueryParameters $params, Quoter $q, array $tokens): $this->nameWithAlias(new QueryParameters(), $q, $join['outer'], $join['alias'], true); } + + return \sprintf( "SELECT%s %s\nFROM %s%s%s%s%s%s%s%s%s%s%s", $this->optional(' ', $this->distinct($params, $q, $tokens['distinct'])), $this->columns($params, $q, $tokens['columns']), \implode(', ', $tables), - $this->optional(' ', $tokens['forUpdate'] ? 'WITH (UPDLOCK,ROWLOCK)' : '', ' '), + $this->optional(' ', $this->forUpdate($tokens['forUpdate']), ' '), $this->optional(' ', $this->joins($params, $q, $tokens['join']), ' '), $this->optional("\nWHERE", $this->where($params, $q, $tokens['where'])), $this->optional("\nGROUP BY", $this->groupBy($params, $q, $tokens['groupBy']), ' '), diff --git a/src/Driver/SQLite/SQLiteCompiler.php b/src/Driver/SQLite/SQLiteCompiler.php index 2ecd5ecb..1a814158 100644 --- a/src/Driver/SQLite/SQLiteCompiler.php +++ b/src/Driver/SQLite/SQLiteCompiler.php @@ -52,7 +52,7 @@ protected function limit(QueryParameters $params, Quoter $q, ?int $limit = null, protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens): string { // FOR UPDATE is not available - $tokens['forUpdate'] = false; + $tokens['forUpdate'] = null; return parent::selectQuery($params, $q, $tokens); } diff --git a/src/Query/Enum/LockBehavior.php b/src/Query/Enum/LockBehavior.php new file mode 100644 index 00000000..0ec06976 --- /dev/null +++ b/src/Query/Enum/LockBehavior.php @@ -0,0 +1,44 @@ +forUpdate = true; + public function forUpdate( + LockMode $mode = LockMode::Update, + LockBehavior $behavior = LockBehavior::Wait, + ): self { + $this->forUpdate = [ + 'mode' => $mode, + 'behavior' => $behavior, + ]; return $this; } diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index d4a37ae1..7438a5df 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -4,6 +4,8 @@ namespace Cycle\Database\Tests\Functional\Driver\Common\Query; +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Exception\BuilderException; use Cycle\Database\Exception\CompilerException; use Cycle\Database\Exception\CompilerException\UnexpectedOperatorException; @@ -2154,12 +2156,12 @@ public function testDirectIsNot(): void ); } - public function testSelectForUpdate(): void + public function testSelectForUpdateLockModeUpdate(): void { $select = $this->database->select() ->from(['users']) ->where('name', 'Antony') - ->forUpdate(); + ->forUpdate(LockMode::Update); $this->assertSameQuery( 'SELECT * FROM {users} WHERE {name} = ? FOR UPDATE', @@ -2167,6 +2169,90 @@ public function testSelectForUpdate(): void ); } + public function testSelectForUpdateLockModeShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::Share); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR SHARE', + $select, + ); + } + + public function testSelectForUpdateLockModeKeyShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::KeyShare); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR KEY SHARE', + $select, + ); + } + + public function testSelectForUpdateLockModeNoKeyUpdate(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::NoKeyUpdate); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR NO KEY UPDATE', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorWait(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::Wait, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR UPDATE', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorNoWait(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::NoWait, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR UPDATE NOWAIT', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorSkipLocked(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::SkipLocked, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR UPDATE SKIP LOCKED', + $select, + ); + } + public function testSelectWithParametricExpression(): void { $select = $this->database->select() diff --git a/tests/Database/Functional/Driver/MySQL/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/MySQL/Query/SelectQueryTest.php index cdb09a6c..fe1c32e5 100644 --- a/tests/Database/Functional/Driver/MySQL/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/MySQL/Query/SelectQueryTest.php @@ -5,6 +5,7 @@ namespace Cycle\Database\Tests\Functional\Driver\MySQL\Query; // phpcs:ignore +use Cycle\Database\Query\Enum\LockMode; use Cycle\Database\Tests\Functional\Driver\Common\Query\SelectQueryTest as CommonClass; /** @@ -506,4 +507,30 @@ public function testOrderByJson(): void $select, ); } + + public function testSelectForUpdateLockModeKeyShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::KeyShare); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR SHARE', + $select, + ); + } + + public function testSelectForUpdateLockModeNoKeyUpdate(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::NoKeyUpdate); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? FOR UPDATE', + $select, + ); + } } diff --git a/tests/Database/Functional/Driver/SQLServer/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/SQLServer/Query/SelectQueryTest.php index 0f3dd69f..2904fbb5 100644 --- a/tests/Database/Functional/Driver/SQLServer/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/SQLServer/Query/SelectQueryTest.php @@ -5,6 +5,8 @@ namespace Cycle\Database\Tests\Functional\Driver\SQLServer\Query; // phpcs:ignore +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Tests\Functional\Driver\Common\Query\SelectQueryTest as CommonClass; /** @@ -100,19 +102,6 @@ public function testOffsetNoLimit(): void ); } - public function testSelectForUpdate(): void - { - $select = $this->database->select() - ->from(['users']) - ->where('name', 'Antony') - ->forUpdate(); - - $this->assertSameQuery( - 'SELECT * FROM {users} WITH(UPDLOCK,ROWLOCK) WHERE {name} = ?', - $select, - ); - } - public function testSelectWithWhereJson(): void { $select = $this->database @@ -561,4 +550,101 @@ public function testOrderByJson(): void $select, ); } + + public function testSelectForUpdateLockModeUpdate(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::Update); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(UPDLOCK) WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockModeShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::Share); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(HOLDLOCK) WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockModeKeyShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::KeyShare); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(HOLDLOCK) WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockModeNoKeyUpdate(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::NoKeyUpdate); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(UPDLOCK) WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorWait(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::Wait, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(UPDLOCK) WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorNoWait(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::NoWait, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(UPDLOCK,NOWAIT) WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorSkipLocked(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::SkipLocked, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WITH(UPDLOCK,READPAST) WHERE {name} = ?', + $select, + ); + } } diff --git a/tests/Database/Functional/Driver/SQLite/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/SQLite/Query/SelectQueryTest.php index 0b56189b..2ebf5963 100644 --- a/tests/Database/Functional/Driver/SQLite/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/SQLite/Query/SelectQueryTest.php @@ -5,6 +5,8 @@ namespace Cycle\Database\Tests\Functional\Driver\SQLite\Query; // phpcs:ignore +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Tests\Functional\Driver\Common\Query\SelectQueryTest as CommonClass; /** @@ -32,19 +34,6 @@ public function testOffsetNoLimit(): void ); } - public function testSelectForUpdate(): void - { - $select = $this->database->select() - ->from(['users']) - ->where('name', 'Antony') - ->forUpdate(); - - $this->assertSameQuery( - 'SELECT * FROM {users} WHERE {name} = ?', - $select, - ); - } - public function testSelectWithWhereJson(): void { $select = $this->database @@ -341,4 +330,101 @@ public function testOrderByJson(): void $select, ); } + + public function testSelectForUpdateLockModeUpdate(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::Update); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockModeShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::Share); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockModeKeyShare(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::KeyShare); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockModeNoKeyUpdate(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate(LockMode::NoKeyUpdate); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorWait(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::Wait, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorNoWait(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::NoWait, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } + + public function testSelectForUpdateLockBehaviorSkipLocked(): void + { + $select = $this->database->select() + ->from(['users']) + ->where('name', 'Antony') + ->forUpdate( + behavior: LockBehavior::SkipLocked, + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ?', + $select, + ); + } } diff --git a/tests/Database/Unit/Query/Tokens/SelectQueryTest.php b/tests/Database/Unit/Query/Tokens/SelectQueryTest.php index 777aabde..8e622d7e 100644 --- a/tests/Database/Unit/Query/Tokens/SelectQueryTest.php +++ b/tests/Database/Unit/Query/Tokens/SelectQueryTest.php @@ -5,6 +5,8 @@ namespace Cycle\Database\Tests\Unit\Query\Tokens; use PHPUnit\Framework\TestCase; +use Cycle\Database\Query\Enum\LockMode; +use Cycle\Database\Query\Enum\LockBehavior; use Cycle\Database\Driver\CompilerInterface; use Cycle\Database\Injection\Parameter; use Cycle\Database\Query\SelectQuery; @@ -17,6 +19,7 @@ public function testBuildQuery(): void $select ->from('table') ->columns('name', 'value') + ->forUpdate(LockMode::KeyShare, LockBehavior::NoWait) ->where(['name' => 'Antony']) ->orWhere('id', '>', 1) ->orderBy('name', 'ASC') @@ -34,7 +37,10 @@ public function testBuildQuery(): void $this->assertEquals( [ - 'forUpdate' => false, + 'forUpdate' => [ + 'mode' => LockMode::KeyShare, + 'behavior' => LockBehavior::NoWait, + ], 'from' => ['table'], 'join' => [], 'columns' => ['name', 'value'],