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
84 changes: 60 additions & 24 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class BaseBuilder
{
use ConditionalTrait;

protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate';
protected const SELECT_LOCK_SHARED = 'shared';

/**
* Reset DELETE data flag
*
Expand Down Expand Up @@ -112,9 +115,9 @@ class BaseBuilder
protected $QBOffset = false;

/**
* QB FOR UPDATE flag
* QB SELECT lock mode
*/
protected bool $QBLockForUpdate = false;
protected ?string $QBSelectLock = null;

/**
* QB SELECT aggregate helper flag
Expand Down Expand Up @@ -1994,7 +1997,17 @@ public function limit(?int $value = null, ?int $offset = 0)
*/
public function lockForUpdate(): static
{
$this->QBLockForUpdate = true;
$this->QBSelectLock = self::SELECT_LOCK_FOR_UPDATE;

return $this;
}

/**
* Locks the selected rows in shared mode.
*/
public function sharedLock(): static
{
$this->QBSelectLock = self::SELECT_LOCK_SHARED;

return $this;
}
Expand Down Expand Up @@ -2262,18 +2275,18 @@ protected function doExists(bool $reset = true)
*/
protected function compileExists(): string
{
// ORDER BY and FOR UPDATE are unnecessary for checking row existence,
// 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;
$lockForUpdate = $this->QBLockForUpdate;
$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->QBLockForUpdate = false;
$this->QBOrderBy = null;
$this->QBSelectLock = null;

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

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

Expand All @@ -2320,11 +2333,11 @@ public function countAllResults(bool $reset = true)
}

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

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

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

if ($this->testMode) {
Expand Down Expand Up @@ -3755,21 +3768,44 @@ protected function compileSelect($selectOverride = false): string
$sql = $this->_limit($sql . "\n");
}

$sql .= $this->compileLockForUpdate();
$sql .= $this->compileSelectLock();

return $this->unionInjection($sql);
}

/**
* Compile the SELECT lock clause.
*/
protected function compileLockForUpdate(): string
protected function compileSelectLock(): string
{
if ($this->QBLockForUpdate && $this->QBUnion !== []) {
throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
if ($this->QBSelectLock === null) {
return '';
}

if ($this->QBUnion !== []) {
throw new DatabaseException(sprintf(
'Query Builder does not support %s() with union() or unionAll().',
$this->selectLockMethod(),
));
}

return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
return match ($this->QBSelectLock) {
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.'),
};
}

/**
* Returns the public method name for the current SELECT lock mode.
*/
protected function selectLockMethod(): string
{
return match ($this->QBSelectLock) {
self::SELECT_LOCK_FOR_UPDATE => 'lockForUpdate',
self::SELECT_LOCK_SHARED => 'sharedLock',
default => 'selectLock',
};
}

/**
Expand Down Expand Up @@ -4115,7 +4151,7 @@ protected function resetSelect()
'QBDistinct' => false,
'QBLimit' => false,
'QBOffset' => false,
'QBLockForUpdate' => false,
'QBSelectLock' => null,
'QBSelectUsesAggregate' => false,
'QBUnion' => [],
]);
Expand Down
15 changes: 11 additions & 4 deletions system/Database/MySQLi/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,26 @@ protected function _fromTables(): string
/**
* Compile the SELECT lock clause.
*/
protected function compileLockForUpdate(): string
protected function compileSelectLock(): string
{
if (! $this->QBLockForUpdate) {
if ($this->QBSelectLock === null) {
return '';
}

foreach ($this->QBFrom as $value) {
if (str_starts_with($value, '(SELECT')) {
throw new DatabaseException('MySQLi does not support lockForUpdate() with fromSubquery().');
throw new DatabaseException(sprintf(
'MySQLi does not support %s() with fromSubquery().',
$this->selectLockMethod(),
));
}
}

return parent::compileLockForUpdate();
if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) {
return "\nLOCK IN SHARE MODE";
}

return parent::compileSelectLock();
}

/**
Expand Down
10 changes: 7 additions & 3 deletions system/Database/OCI8/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,16 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string
/**
* Compile the SELECT lock clause.
*/
protected function compileLockForUpdate(): string
protected function compileSelectLock(): string
{
if (! $this->QBLockForUpdate) {
if ($this->QBSelectLock === null) {
return '';
}

if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) {
throw new DatabaseException('OCI8 does not support sharedLock().');
}

if ($this->QBLimit !== false || $this->QBOffset) {
throw new DatabaseException('OCI8 does not support lockForUpdate() with limit() or offset().');
}
Expand All @@ -230,7 +234,7 @@ protected function compileLockForUpdate(): string
throw new DatabaseException('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.');
}

return parent::compileLockForUpdate();
return parent::compileSelectLock();
}

/**
Expand Down
11 changes: 7 additions & 4 deletions system/Database/Postgre/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,20 @@ protected function compileIgnore(string $statement)
/**
* Compile the SELECT lock clause.
*/
protected function compileLockForUpdate(): string
protected function compileSelectLock(): string
{
if (! $this->QBLockForUpdate) {
if ($this->QBSelectLock === null) {
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.');
throw new DatabaseException(sprintf(
'Postgre does not support %s() with distinct(), groupBy(), having(), or aggregate helper selections.',
$this->selectLockMethod(),
));
}

return parent::compileLockForUpdate();
return parent::compileSelectLock();
}

/**
Expand Down
36 changes: 29 additions & 7 deletions system/Database/SQLSRV/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
class Builder extends BaseBuilder
{
private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)';
private const SHARED_LOCK_HINT = ' WITH (HOLDLOCK, ROWLOCK)';

/**
* ORDER BY random keyword
Expand Down Expand Up @@ -84,12 +85,24 @@ protected function _fromTables(): string
continue;
}

$from[] = $this->getFullName($value) . ($this->QBLockForUpdate ? self::LOCK_FOR_UPDATE_HINT : '');
$from[] = $this->getFullName($value) . $this->compileTableLockHint();
}

return implode(', ', $from);
}

/**
* Compile the SQL Server table hint for the current SELECT lock mode.
*/
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 => '',
};
}

/**
* Generates a platform-specific truncate string from the supplied data
*
Expand Down Expand Up @@ -615,31 +628,40 @@ protected function compileSelect($selectOverride = false): string
$sql = $this->_limit($sql . "\n");
}

$sql .= $this->compileLockForUpdate();
$sql .= $this->compileSelectLock();

return $this->unionInjection($sql);
}

/**
* Compile the SELECT lock clause.
*/
protected function compileLockForUpdate(): string
protected function compileSelectLock(): string
{
if (! $this->QBLockForUpdate) {
if ($this->QBSelectLock === null) {
return '';
}

if ($this->QBFrom === []) {
throw new DatabaseException('SQLSRV does not support lockForUpdate() without a FROM table.');
throw new DatabaseException(sprintf(
'SQLSRV does not support %s() without a FROM table.',
$this->selectLockMethod(),
));
}

if ($this->QBUnion !== []) {
throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
throw new DatabaseException(sprintf(
'Query Builder does not support %s() with union() or unionAll().',
$this->selectLockMethod(),
));
}

foreach ($this->QBFrom as $value) {
if (str_starts_with($value, '(SELECT')) {
throw new DatabaseException('SQLSRV does not support lockForUpdate() on subqueries.');
throw new DatabaseException(sprintf(
'SQLSRV does not support %s() on subqueries.',
$this->selectLockMethod(),
));
}
}

Expand Down
9 changes: 6 additions & 3 deletions system/Database/SQLite3/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ class Builder extends BaseBuilder
/**
* Compile the SELECT lock clause.
*/
protected function compileLockForUpdate(): string
protected function compileSelectLock(): string
{
if ($this->QBLockForUpdate) {
throw new DatabaseException('SQLite3 does not support lockForUpdate().');
if ($this->QBSelectLock !== null) {
throw new DatabaseException(sprintf(
'SQLite3 does not support %s().',
$this->selectLockMethod(),
));
}

return '';
Expand Down
28 changes: 28 additions & 0 deletions tests/system/Database/Builder/CountTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ public function testCountAllResultsDoesNotUseLockForUpdate(): void
$this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', $builder->getCompiledSelect(false));
}

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

$answer = $builder->where('id >', 3)->sharedLock()->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 SHARE', $builder->getCompiledSelect(false));
}

public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void
{
$this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']);
Expand All @@ -84,6 +97,21 @@ public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void
$this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false));
}

public function testCountAllResultsWithSQLSRVDoesNotUseSharedLock(): void
{
$this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']);

$builder = new SQLSRVBuilder('jobs', $this->db);
$builder->testMode();

$answer = $builder->where('id >', 3)->sharedLock()->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 (HOLDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false));
}

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