Skip to content

Commit

Permalink
Add skipLocked() to the query builder
Browse files Browse the repository at this point in the history
This will allow us to create a query that will not
wait for a lock to be released on rows, and instead
return a row set excluding the rows locked by
another transaction.
  • Loading branch information
Herberto Graca authored and hgraca committed Sep 14, 2023
1 parent e4a7610 commit a979665
Show file tree
Hide file tree
Showing 14 changed files with 354 additions and 3 deletions.
8 changes: 8 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -1757,6 +1757,14 @@ public function getForUpdateSQL()
return 'FOR UPDATE';
}

/**
* Returns the FOR UPDATE SKIP LOCKED expression.
*/
public function getSkipLockedSQL(): string
{
return 'SKIP LOCKED';
}

/**
* Honors that some SQL vendors such as MsSql use table hints for locking instead of the
* ANSI SQL FOR UPDATE specification.
Expand Down
5 changes: 5 additions & 0 deletions src/Platforms/DB2111Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ protected function doModifyLimitQuery($query, $limit, $offset)

return $query;
}

public function getSkipLockedSQL(): string
{
return 'SKIP LOCKED DATA';
}
}
5 changes: 5 additions & 0 deletions src/Platforms/DB2Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -1041,4 +1041,9 @@ public function createSchemaManager(Connection $connection): DB2SchemaManager
{
return new DB2SchemaManager($connection, $this);
}

public function getSkipLockedSQL(): string
{
return '';
}
}
7 changes: 7 additions & 0 deletions src/Platforms/MariaDb1027Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@
*/
class MariaDb1027Platform extends MariaDBPlatform
{
/**
* Returns the FOR UPDATE expression, as SKIP LOCKED is only available since MariaDB 10.6.0.
*/
public function getSkipLockedSQL(): string
{
return '';
}
}
21 changes: 21 additions & 0 deletions src/Platforms/MariaDb1060Platform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Doctrine\DBAL\Platforms;

/**
* Provides the behavior, features and SQL dialect of the MariaDB 10.6 (10.6.0 GA) database platform.
*
* Note: Should not be used with versions prior to 10.6.0.
*/
class MariaDb1060Platform extends MariaDb1052Platform
{
/**
* Returns the FOR UPDATE SKIP LOCKED expression.
* This method will become obsolete once the minimum MariaDb version is at least 10.6.0,
* as this method already exists in the base AbstractPlatform class.
*/
public function getSkipLockedSQL(): string
{
return 'SKIP LOCKED';
}
}
8 changes: 8 additions & 0 deletions src/Platforms/MySQL57Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,12 @@ protected function initializeDoctrineTypeMappings()

$this->doctrineTypeMapping['json'] = Types::JSON;
}

/**
* Returns '', as SKIP LOCKED is only available since MySQL 8.
*/
public function getSkipLockedSQL(): string
{
return '';
}
}
10 changes: 10 additions & 0 deletions src/Platforms/MySQL80Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,14 @@ protected function getReservedKeywordsClass()

return Keywords\MySQL80Keywords::class;
}

/**
* Returns the SKIP LOCKED expression.
* When support for MySQL 5.7 is removed, this method can be removed from this
* class as it is already implemented in the base class.
*/
public function getSkipLockedSQL(): string
{
return 'SKIP LOCKED';
}
}
5 changes: 5 additions & 0 deletions src/Platforms/SQLServerPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,11 @@ public function getForUpdateSQL()
return 'WITH (UPDLOCK, ROWLOCK)';
}

public function getSkipLockedSQL(): string
{
return 'WITH (READPAST)';
}

/**
* {@inheritDoc}
*
Expand Down
5 changes: 5 additions & 0 deletions src/Platforms/SqlitePlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,11 @@ public function getForUpdateSQL()
return '';
}

public function getSkipLockedSQL(): string
{
return '';
}

/**
* {@inheritDoc}
*
Expand Down
12 changes: 12 additions & 0 deletions src/Query/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1650,4 +1650,16 @@ public function lockForUpdate(): self

return $this;
}

/**
* Sets a lock on the queried rows, until the end of the transaction
*
* @return $this
*/
public function skipLocked(): self
{
$this->sqlParts['locks'][] = QueryLockBuilder::SKIP_LOCKED;

return $this;
}
}
6 changes: 5 additions & 1 deletion src/Query/QueryLockBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
/** @internal */
final class QueryLockBuilder
{
public const FOR_UPDATE = 'FOR_UPDATE';
public const FOR_UPDATE = 'FOR_UPDATE';
public const SKIP_LOCKED = 'SKIP_LOCKED';

private AbstractPlatform $platform;

Expand All @@ -43,6 +44,9 @@ public function getLocksSql(string ...$lockList): string
case self::FOR_UPDATE:
$locksSql[0] = $this->platform->getForUpdateSQL();
break;
case self::SKIP_LOCKED:
$locksSql[1] = $this->platform->getSkipLockedSQL();
break;
}
}

Expand Down
5 changes: 3 additions & 2 deletions tests/Driver/VersionAwarePlatformDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Doctrine\DBAL\Platforms\DB2Platform;
use Doctrine\DBAL\Platforms\MariaDb1027Platform;
use Doctrine\DBAL\Platforms\MariaDb1052Platform;
use Doctrine\DBAL\Platforms\MariaDb1060Platform;
use Doctrine\DBAL\Platforms\MySQL57Platform;
use Doctrine\DBAL\Platforms\MySQL80Platform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
Expand Down Expand Up @@ -73,7 +74,7 @@ public static function mySQLVersionProvider(): array
'https://github.com/doctrine/dbal/pull/5779',
false,
],
['mariadb-10.9.3', MariaDB1052Platform::class, 'https://github.com/doctrine/dbal/pull/5779', true],
['mariadb-10.9.3', MariaDb1060Platform::class, 'https://github.com/doctrine/dbal/pull/5779', true],
[
'10.5.2-MariaDB-1~lenny-log',
MariaDB1052Platform::class,
Expand All @@ -82,7 +83,7 @@ public static function mySQLVersionProvider(): array
],
[
'11.0.2-MariaDB-1:11.0.2+maria~ubu2204',
MariaDB1052Platform::class,
MariaDb1060Platform::class,
'https://github.com/doctrine/dbal/pull/5779',
false,
],
Expand Down
79 changes: 79 additions & 0 deletions tests/Functional/Query/QueryBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Tests\Functional\Query;

use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Tests\FunctionalTestCase;
use Doctrine\DBAL\Tests\TestUtil;
use Doctrine\DBAL\Types\Types;

final class QueryBuilderTest extends FunctionalTestCase
{
protected function setUp(): void
{
$tableName = 'users';
$table = new Table($tableName);
$table->addColumn('id', Types::INTEGER, ['autoincrement' => true]);
$table->addColumn('nickname', Types::STRING);
$table->setPrimaryKey(['id']);

$this->dropAndCreateTable($table);

$this->connection->insert($tableName, ['nickname' => 'aaa']);
$this->connection->insert($tableName, ['nickname' => 'bbb']);
}

public function testConcurrentConnectionSkipsLockedRows(): void
{
$connection1 = $this->connection;
$qb1 = new QueryBuilder($connection1);
$qb1->select('u.id')
->from('users', 'u')
->orderBy('id', 'ASC')
->setMaxResults(1)
->lockForUpdate()
->skipLocked();

self::assertFalse($connection1->isTransactionActive(), 'A transaction should not be active on connection 1');
$connection1->beginTransaction();
self::assertTrue($connection1->isTransactionActive(), 'A transaction should be active on connection 1');
$result = $connection1->executeQuery($qb1->getSQL());
$resultList = $result->fetchAllAssociative();
self::assertCount(1, $resultList);
self::assertEquals(1, $resultList[0]['id']);

$connection2 = TestUtil::getConnection();
self::assertTrue(
$connection1 !== $connection2,
"The two competing connections must be different, but they are the same so we can't run this test with it.",
);
self::assertFalse($connection2->isTransactionActive(), 'A transaction should not be active on connection 2');

$qb2 = new QueryBuilder($connection2);
$qb2->select('u.id')
->from('users', 'u')
->orderBy('id', 'ASC')
->setMaxResults(1)
->lockForUpdate()
->skipLocked();

self::assertTrue($connection1->isTransactionActive(), 'A transaction should still be active on connection 1');
$result = $connection2->executeQuery($qb2->getSQL());
$resultList = $result->fetchAllAssociative();
self::assertCount(1, $resultList);
self::assertEquals(2, $resultList[0]['id']);

$connection1->commit();
self::assertFalse(
$connection1->isTransactionActive(),
'A transaction should not be active anymore on connection 1',
);
$result = $connection2->executeQuery($qb2->getSQL());
$resultList = $result->fetchAllAssociative();
self::assertCount(1, $resultList);
self::assertEquals(1, $resultList[0]['id']);
}
}
Loading

0 comments on commit a979665

Please sign in to comment.