Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 103 additions & 23 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ class BaseBuilder
{
use ConditionalTrait;

protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate';
protected const SELECT_LOCK_SHARED = 'shared';
protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate';
protected const SELECT_LOCK_SHARED = 'shared';
protected const SELECT_LOCK_WAIT_NOWAIT = 'nowait';
protected const SELECT_LOCK_WAIT_SKIP_LOCKED = 'skipLocked';

/**
* Reset DELETE data flag
Expand Down Expand Up @@ -119,6 +121,11 @@ class BaseBuilder
*/
protected ?string $QBSelectLock = null;

/**
* QB SELECT lock wait behavior
*/
protected ?string $QBSelectLockWait = null;

/**
* QB SELECT aggregate helper flag
*/
Expand Down Expand Up @@ -2012,6 +2019,26 @@ public function sharedLock(): static
return $this;
}

/**
* Fails immediately when selected rows cannot be locked.
*/
public function nowait(): static
{
$this->QBSelectLockWait = self::SELECT_LOCK_WAIT_NOWAIT;

return $this;
}

/**
* Skips selected rows that cannot be locked immediately.
*/
public function skipLocked(): static
{
$this->QBSelectLockWait = self::SELECT_LOCK_WAIT_SKIP_LOCKED;

return $this;
}

/**
* Sets the OFFSET value
*
Expand Down Expand Up @@ -2275,18 +2302,22 @@ protected function doExists(bool $reset = true)
*/
protected function compileExists(): string
{
$this->assertSelectLockWaitHasLock();

// 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;
$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->QBSelectLock = null;
$orderBy = $this->QBOrderBy;
$limit = $this->QBLimit;
$offset = $this->QBOffset;
$selectLock = $this->QBSelectLock;
$selectLockWait = $this->QBSelectLockWait;
$select = $this->QBSelect;
$noEscape = $this->QBNoEscape;
$needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;

$this->QBOrderBy = null;
$this->QBSelectLock = null;
$this->QBSelectLockWait = null;
Comment thread
memleakd marked this conversation as resolved.

if (! $needsSubquery && $this->QBLimit !== 0) {
$this->QBLimit = 1;
Expand All @@ -2304,12 +2335,13 @@ protected function compileExists(): string

return $this->compileSelect('SELECT 1');
} finally {
$this->QBOrderBy = $orderBy;
$this->QBLimit = $limit;
$this->QBOffset = $offset;
$this->QBSelectLock = $selectLock;
$this->QBSelect = $select;
$this->QBNoEscape = $noEscape;
$this->QBOrderBy = $orderBy;
$this->QBLimit = $limit;
$this->QBOffset = $offset;
$this->QBSelectLock = $selectLock;
$this->QBSelectLockWait = $selectLockWait;
$this->QBSelect = $select;
$this->QBNoEscape = $noEscape;
}
}

Expand All @@ -2321,6 +2353,8 @@ protected function compileExists(): string
*/
public function countAllResults(bool $reset = true)
{
$this->assertSelectLockWaitHasLock();

// ORDER BY usage is often problematic here (most notably
// on Microsoft SQL Server) and ultimately unnecessary
// for selecting COUNT(*) ...
Expand All @@ -2333,11 +2367,13 @@ public function countAllResults(bool $reset = true)
}

// We cannot use a LIMIT when getting the single row COUNT(*) result
$limit = $this->QBLimit;
$selectLock = $this->QBSelectLock;
$limit = $this->QBLimit;
$selectLock = $this->QBSelectLock;
$selectLockWait = $this->QBSelectLockWait;

$this->QBLimit = false;
$this->QBSelectLock = null;
$this->QBLimit = false;
$this->QBSelectLock = null;
$this->QBSelectLockWait = null;

try {
if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
Expand All @@ -2352,7 +2388,8 @@ public function countAllResults(bool $reset = true)
$sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
}
} finally {
$this->QBSelectLock = $selectLock;
$this->QBSelectLock = $selectLock;
$this->QBSelectLockWait = $selectLockWait;
}

if ($this->testMode) {
Expand Down Expand Up @@ -3778,6 +3815,8 @@ protected function compileSelect($selectOverride = false): string
*/
protected function compileSelectLock(): string
{
$this->assertSelectLockWaitHasLock();

if ($this->QBSelectLock === null) {
return '';
}
Expand All @@ -3793,9 +3832,37 @@ protected function compileSelectLock(): string
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.'),
} . $this->compileSelectLockWait();
}

/**
* Compile the SELECT lock wait behavior.
*/
protected function compileSelectLockWait(): string
{
return match ($this->QBSelectLockWait) {
self::SELECT_LOCK_WAIT_NOWAIT => ' NOWAIT',
self::SELECT_LOCK_WAIT_SKIP_LOCKED => ' SKIP LOCKED',
null => '',
default => throw new DatabaseException('Query Builder has an invalid SELECT lock wait behavior.'),
};
}

/**
* Ensures SELECT lock wait behavior has a pessimistic lock to modify.
*/
protected function assertSelectLockWaitHasLock(): void
{
if ($this->QBSelectLock !== null || $this->QBSelectLockWait === null) {
return;
}

throw new DatabaseException(sprintf(
'Query Builder does not support %s() without lockForUpdate() or sharedLock().',
$this->selectLockWaitMethod(),
));
}

/**
* Returns the public method name for the current SELECT lock mode.
*/
Expand All @@ -3808,6 +3875,18 @@ protected function selectLockMethod(): string
};
}

/**
* Returns the public method name for the current SELECT lock wait behavior.
*/
protected function selectLockWaitMethod(): string
{
return match ($this->QBSelectLockWait) {
self::SELECT_LOCK_WAIT_NOWAIT => 'nowait',
self::SELECT_LOCK_WAIT_SKIP_LOCKED => 'skipLocked',
default => 'selectLockWait',
};
}

/**
* Checks if the ignore option is supported by
* the Database Driver for the specific statement.
Expand Down Expand Up @@ -4152,6 +4231,7 @@ protected function resetSelect()
'QBLimit' => false,
'QBOffset' => false,
'QBSelectLock' => null,
'QBSelectLockWait' => null,
'QBSelectUsesAggregate' => false,
'QBUnion' => [],
]);
Expand Down
9 changes: 8 additions & 1 deletion system/Database/MySQLi/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ protected function _fromTables(): string
protected function compileSelectLock(): string
{
if ($this->QBSelectLock === null) {
return '';
return parent::compileSelectLock();
}

foreach ($this->QBFrom as $value) {
Expand All @@ -77,6 +77,13 @@ protected function compileSelectLock(): string
}

if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) {
if ($this->QBSelectLockWait !== null) {
throw new DatabaseException(sprintf(
'MySQLi does not support sharedLock() with %s().',
$this->selectLockWaitMethod(),
));
}

return "\nLOCK IN SHARE MODE";
}

Expand Down
2 changes: 1 addition & 1 deletion system/Database/OCI8/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string
protected function compileSelectLock(): string
{
if ($this->QBSelectLock === null) {
return '';
return parent::compileSelectLock();
}

if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) {
Expand Down
2 changes: 1 addition & 1 deletion system/Database/Postgre/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ protected function compileIgnore(string $statement)
protected function compileSelectLock(): string
{
if ($this->QBSelectLock === null) {
return '';
return parent::compileSelectLock();
}

if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) {
Expand Down
34 changes: 27 additions & 7 deletions system/Database/SQLSRV/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
*/
class Builder extends BaseBuilder
{
private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)';
private const SHARED_LOCK_HINT = ' WITH (HOLDLOCK, ROWLOCK)';
private const LOCK_FOR_UPDATE_HINTS = ['UPDLOCK', 'ROWLOCK'];
private const LOCK_FOR_UPDATE_SKIP_LOCKED_HINTS = ['UPDLOCK', 'READCOMMITTEDLOCK', 'READPAST'];
private const SHARED_LOCK_HINTS = ['HOLDLOCK', 'ROWLOCK'];

/**
* ORDER BY random keyword
Expand Down Expand Up @@ -96,11 +97,23 @@ protected function _fromTables(): string
*/
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 => '',
$hints = match ($this->QBSelectLock) {
self::SELECT_LOCK_FOR_UPDATE => $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED
? self::LOCK_FOR_UPDATE_SKIP_LOCKED_HINTS
: self::LOCK_FOR_UPDATE_HINTS,
self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINTS,
default => [],
};

if ($hints === []) {
return '';
}

if ($this->QBSelectLockWait === self::SELECT_LOCK_WAIT_NOWAIT) {
$hints[] = 'NOWAIT';
}

return ' WITH (' . implode(', ', $hints) . ')';
}

/**
Expand Down Expand Up @@ -639,7 +652,14 @@ protected function compileSelect($selectOverride = false): string
protected function compileSelectLock(): string
{
if ($this->QBSelectLock === null) {
return '';
return parent::compileSelectLock();
}

if (
$this->QBSelectLock === self::SELECT_LOCK_SHARED
&& $this->QBSelectLockWait === self::SELECT_LOCK_WAIT_SKIP_LOCKED
) {
throw new DatabaseException('SQLSRV does not support sharedLock() with skipLocked().');
}

if ($this->QBFrom === []) {
Expand Down
12 changes: 6 additions & 6 deletions system/Database/SQLite3/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ class Builder extends BaseBuilder
*/
protected function compileSelectLock(): string
{
if ($this->QBSelectLock !== null) {
throw new DatabaseException(sprintf(
'SQLite3 does not support %s().',
$this->selectLockMethod(),
));
if ($this->QBSelectLock === null) {
return parent::compileSelectLock();
}

return '';
throw new DatabaseException(sprintf(
'SQLite3 does not support %s().',
$this->selectLockMethod(),
));
}

/**
Expand Down
19 changes: 15 additions & 4 deletions tests/system/Database/Builder/CountTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace CodeIgniter\Database\Builder;

use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockConnection;
Expand Down Expand Up @@ -61,12 +62,22 @@ public function testCountAllResultsDoesNotUseLockForUpdate(): void
$builder = new BaseBuilder('jobs', $this->db);
$builder->testMode();

$answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false);
$answer = $builder->where('id >', 3)->lockForUpdate()->skipLocked()->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 UPDATE', $builder->getCompiledSelect(false));
$this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE SKIP LOCKED', $builder->getCompiledSelect(false));
}

public function testCountAllResultsThrowsExceptionWithSelectLockWaitWithoutSelectLock(): void
{
$builder = new BaseBuilder('jobs', $this->db);

$this->expectException(DatabaseException::class);
$this->expectExceptionMessage('Query Builder does not support skipLocked() without lockForUpdate() or sharedLock().');

$builder->skipLocked()->countAllResults();
}

public function testCountAllResultsDoesNotUseSharedLock(): void
Expand All @@ -89,12 +100,12 @@ public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void
$builder = new SQLSRVBuilder('jobs', $this->db);
$builder->testMode();

$answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false);
$answer = $builder->where('id >', 3)->lockForUpdate()->nowait()->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 (UPDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false));
$this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK, NOWAIT) WHERE "id" > 3', $builder->getCompiledSelect(false));
}

public function testCountAllResultsWithSQLSRVDoesNotUseSharedLock(): void
Expand Down
Loading
Loading