diff --git a/src/Driver/AbstractMySQLDriver.php b/src/Driver/AbstractMySQLDriver.php index 5f6ffd6f5f9..83159a540da 100644 --- a/src/Driver/AbstractMySQLDriver.php +++ b/src/Driver/AbstractMySQLDriver.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Platforms\MariaDb1027Platform; use Doctrine\DBAL\Platforms\MariaDb1043Platform; 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; @@ -39,6 +40,10 @@ public function createDatabasePlatformForVersion($version) if ($mariadb) { $mariaDbVersion = $this->getMariaDbMysqlVersionNumber($version); + if (version_compare($mariaDbVersion, '10.6.0', '>=')) { + return new MariaDb1060Platform(); + } + if (version_compare($mariaDbVersion, '10.5.2', '>=')) { return new MariaDb1052Platform(); } diff --git a/src/Id/TableGenerator.php b/src/Id/TableGenerator.php index 7d7f210e219..51e541d54ff 100644 --- a/src/Id/TableGenerator.php +++ b/src/Id/TableGenerator.php @@ -6,7 +6,6 @@ use Doctrine\DBAL\Driver; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception; -use Doctrine\DBAL\LockMode; use Doctrine\Deprecations\Deprecation; use Throwable; @@ -115,11 +114,13 @@ public function nextValue($sequence) $this->conn->beginTransaction(); try { - $platform = $this->conn->getDatabasePlatform(); - $sql = 'SELECT sequence_value, sequence_increment_by' - . ' FROM ' . $platform->appendLockHint($this->generatorTableName, LockMode::PESSIMISTIC_WRITE) - . ' WHERE sequence_name = ? ' . $platform->getWriteLockSQL(); - $row = $this->conn->fetchAssociative($sql, [$sequence]); + $row = $this->conn->createQueryBuilder() + ->select('sequence_value', 'sequence_increment_by') + ->from($this->generatorTableName) + ->where('sequence_name = ?') + ->forUpdate() + ->setParameter(1, $sequence) + ->fetchAssociative(); if ($row !== false) { $row = array_change_key_case($row, CASE_LOWER); diff --git a/src/Platforms/AbstractMySQLPlatform.php b/src/Platforms/AbstractMySQLPlatform.php index d1e3e5aff52..79aaaf4a6ed 100644 --- a/src/Platforms/AbstractMySQLPlatform.php +++ b/src/Platforms/AbstractMySQLPlatform.php @@ -11,6 +11,8 @@ use Doctrine\DBAL\Schema\MySQLSchemaManager; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; +use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\TransactionIsolationLevel; use Doctrine\DBAL\Types\BlobType; use Doctrine\DBAL\Types\TextType; @@ -522,6 +524,11 @@ protected function _getCreateTableSQL($name, array $columns, array $options = [] return $sql; } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, $this->getForUpdateSQL(), null); + } + /** * {@inheritDoc} * diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index eba3aefc890..6ed64955b9a 100644 --- a/src/Platforms/AbstractPlatform.php +++ b/src/Platforms/AbstractPlatform.php @@ -29,6 +29,8 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use Doctrine\DBAL\Schema\UniqueConstraint; +use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; +use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\SQL\Parser; use Doctrine\DBAL\TransactionIsolationLevel; use Doctrine\DBAL\Types; @@ -2052,6 +2054,11 @@ public function getCreateTableSQL(Table $table, $createFlags = self::CREATE_INDE ); } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, $this->getForUpdateSQL(), 'SKIP LOCKED'); + } + /** * @internal * diff --git a/src/Platforms/DB2Platform.php b/src/Platforms/DB2Platform.php index b203ce8a052..a2ac9524744 100644 --- a/src/Platforms/DB2Platform.php +++ b/src/Platforms/DB2Platform.php @@ -9,6 +9,8 @@ use Doctrine\DBAL\Schema\Identifier; use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; +use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Doctrine\Deprecations\Deprecation; @@ -974,6 +976,11 @@ public function prefersIdentityColumns() return true; } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, $this->getForUpdateSQL(), null); + } + /** * {@inheritDoc} */ diff --git a/src/Platforms/MariaDb1060Platform.php b/src/Platforms/MariaDb1060Platform.php new file mode 100644 index 00000000000..82d11f21d41 --- /dev/null +++ b/src/Platforms/MariaDb1060Platform.php @@ -0,0 +1,16 @@ +getForUpdateSQL(), null); + } + /** * {@inheritDoc} * diff --git a/src/Platforms/SQLServer/SQL/Builder/SQLServerSelectSQLBuilder.php b/src/Platforms/SQLServer/SQL/Builder/SQLServerSelectSQLBuilder.php new file mode 100644 index 00000000000..781efc65a65 --- /dev/null +++ b/src/Platforms/SQLServer/SQL/Builder/SQLServerSelectSQLBuilder.php @@ -0,0 +1,84 @@ +platform = $platform; + } + + public function buildSQL(SelectQuery $query): string + { + $parts = ['SELECT']; + + if ($query->isDistinct()) { + $parts[] = 'DISTINCT'; + } + + $parts[] = implode(', ', $query->getColumns()); + + $from = $query->getFrom(); + + if (count($from) > 0) { + $parts[] = 'FROM ' . implode(', ', $from); + } + + $forUpdate = $query->getForUpdate(); + + if ($forUpdate !== null) { + $with = ['UPDLOCK', 'ROWLOCK']; + + if ($forUpdate->shouldSkipLocked()) { + $with[] = 'READPAST'; + } + + $parts[] = 'WITH (' . implode(', ', $with) . ')'; + } + + $where = $query->getWhere(); + + if ($where !== null) { + $parts[] = 'WHERE ' . $where; + } + + $groupBy = $query->getGroupBy(); + + if (count($groupBy) > 0) { + $parts[] = 'GROUP BY ' . implode(', ', $groupBy); + } + + $having = $query->getHaving(); + + if ($having !== null) { + $parts[] = 'HAVING ' . $having; + } + + $orderBy = $query->getOrderBy(); + + if (count($orderBy) > 0) { + $parts[] = 'ORDER BY ' . implode(', ', $orderBy); + } + + $sql = implode(' ', $parts); + $limit = $query->getLimit(); + + if ($limit->isDefined()) { + $sql = $this->platform->modifyLimitQuery($sql, $limit->getMaxResults(), $limit->getFirstResult()); + } + + return $sql; + } +} diff --git a/src/Platforms/SQLServerPlatform.php b/src/Platforms/SQLServerPlatform.php index 113055ba896..d9a043b5df6 100644 --- a/src/Platforms/SQLServerPlatform.php +++ b/src/Platforms/SQLServerPlatform.php @@ -5,6 +5,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\InvalidLockMode; use Doctrine\DBAL\LockMode; +use Doctrine\DBAL\Platforms\SQLServer\SQL\Builder\SQLServerSelectSQLBuilder; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\ColumnDiff; use Doctrine\DBAL\Schema\ForeignKeyConstraint; @@ -14,6 +15,7 @@ use Doctrine\DBAL\Schema\SQLServerSchemaManager; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\Types\Types; use Doctrine\Deprecations\Deprecation; use InvalidArgumentException; @@ -49,6 +51,11 @@ */ class SQLServerPlatform extends AbstractPlatform { + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new SQLServerSelectSQLBuilder($this); + } + /** * {@inheritDoc} */ diff --git a/src/Platforms/SqlitePlatform.php b/src/Platforms/SqlitePlatform.php index 5acefc5c82f..03f4f61037a 100644 --- a/src/Platforms/SqlitePlatform.php +++ b/src/Platforms/SqlitePlatform.php @@ -14,6 +14,8 @@ use Doctrine\DBAL\Schema\SqliteSchemaManager; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; +use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\TransactionIsolationLevel; use Doctrine\DBAL\Types; use Doctrine\DBAL\Types\IntegerType; @@ -193,6 +195,11 @@ public function getCurrentDatabaseExpression(): string return "'main'"; } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, $this->getForUpdateSQL(), null); + } + /** * {@inheritDoc} */ diff --git a/src/Query/ForUpdate.php b/src/Query/ForUpdate.php new file mode 100644 index 00000000000..d98d464d864 --- /dev/null +++ b/src/Query/ForUpdate.php @@ -0,0 +1,21 @@ +shouldSkipLocked = $shouldSkipLocked; + } + + public function shouldSkipLocked(): bool + { + return $this->shouldSkipLocked; + } +} diff --git a/src/Query/Limit.php b/src/Query/Limit.php new file mode 100644 index 00000000000..f5ce5a7231f --- /dev/null +++ b/src/Query/Limit.php @@ -0,0 +1,30 @@ +maxResults = $maxResults; + $this->firstResult = $firstResult; + } + + public function isDefined(): bool + { + return $this->maxResults !== null || $this->firstResult !== 0; + } + + public function getMaxResults(): ?int + { + return $this->maxResults; + } + + public function getFirstResult(): int + { + return $this->firstResult; + } +} diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php index c0d18d5f2ff..8768b2bf8af 100644 --- a/src/Query/QueryBuilder.php +++ b/src/Query/QueryBuilder.php @@ -65,16 +65,17 @@ class QueryBuilder * The default values of SQL parts collection */ private const SQL_PARTS_DEFAULTS = [ - 'select' => [], - 'distinct' => false, - 'from' => [], - 'join' => [], - 'set' => [], - 'where' => null, - 'groupBy' => [], - 'having' => null, - 'orderBy' => [], - 'values' => [], + 'select' => [], + 'distinct' => false, + 'from' => [], + 'join' => [], + 'set' => [], + 'where' => null, + 'groupBy' => [], + 'having' => null, + 'orderBy' => [], + 'values' => [], + 'for_update' => null, ]; /** @@ -586,6 +587,20 @@ public function getMaxResults() return $this->maxResults; } + /** + * Locks the queried rows for a subsequent update. + * + * @return $this + */ + public function forUpdate(bool $shouldSkipLocked = false): self + { + $this->state = self::STATE_DIRTY; + + $this->sqlParts['for_update'] = new ForUpdate($shouldSkipLocked); + + return $this; + } + /** * Either appends to or replaces a single, generic query part. * @@ -1380,27 +1395,24 @@ public function resetOrderBy(): self return $this; } - /** @throws QueryException */ + /** @throws Exception */ private function getSQLForSelect(): string { - $query = 'SELECT ' . ($this->sqlParts['distinct'] ? 'DISTINCT ' : '') . - implode(', ', $this->sqlParts['select']); - - $query .= ($this->sqlParts['from'] ? ' FROM ' . implode(', ', $this->getFromClauses()) : '') - . ($this->sqlParts['where'] !== null ? ' WHERE ' . ((string) $this->sqlParts['where']) : '') - . ($this->sqlParts['groupBy'] ? ' GROUP BY ' . implode(', ', $this->sqlParts['groupBy']) : '') - . ($this->sqlParts['having'] !== null ? ' HAVING ' . ((string) $this->sqlParts['having']) : '') - . ($this->sqlParts['orderBy'] ? ' ORDER BY ' . implode(', ', $this->sqlParts['orderBy']) : ''); - - if ($this->isLimitQuery()) { - return $this->connection->getDatabasePlatform()->modifyLimitQuery( - $query, - $this->maxResults, - $this->firstResult, + return $this->connection->getDatabasePlatform() + ->createSelectSQLBuilder() + ->buildSQL( + new SelectQuery( + $this->sqlParts['distinct'], + $this->sqlParts['select'], + $this->getFromClauses(), + $this->sqlParts['where'], + $this->sqlParts['groupBy'], + $this->sqlParts['having'], + $this->sqlParts['orderBy'], + new Limit($this->maxResults, $this->firstResult), + $this->sqlParts['for_update'], + ), ); - } - - return $query; } /** @@ -1447,11 +1459,6 @@ private function verifyAllAliasesAreKnown(array $knownAliases): void } } - private function isLimitQuery(): bool - { - return $this->maxResults !== null || $this->firstResult !== 0; - } - /** * Converts this instance into an INSERT string in SQL. */ diff --git a/src/Query/SelectQuery.php b/src/Query/SelectQuery.php new file mode 100644 index 00000000000..09e21227de7 --- /dev/null +++ b/src/Query/SelectQuery.php @@ -0,0 +1,105 @@ +distinct = $distinct; + $this->columns = $columns; + $this->from = $from; + $this->where = $where; + $this->groupBy = $groupBy; + $this->having = $having; + $this->orderBy = $orderBy; + $this->limit = $limit; + $this->forUpdate = $forUpdate; + } + + public function isDistinct(): bool + { + return $this->distinct; + } + + /** @return string[] */ + public function getColumns(): array + { + return $this->columns; + } + + /** @return string[] */ + public function getFrom(): array + { + return $this->from; + } + + public function getWhere(): ?string + { + return $this->where; + } + + /** @return string[] */ + public function getGroupBy(): array + { + return $this->groupBy; + } + + public function getHaving(): ?string + { + return $this->having; + } + + /** @return string[] */ + public function getOrderBy(): array + { + return $this->orderBy; + } + + public function getLimit(): Limit + { + return $this->limit; + } + + public function getForUpdate(): ?ForUpdate + { + return $this->forUpdate; + } +} diff --git a/src/SQL/Builder/DefaultSelectSQLBuilder.php b/src/SQL/Builder/DefaultSelectSQLBuilder.php new file mode 100644 index 00000000000..9527d226a5a --- /dev/null +++ b/src/SQL/Builder/DefaultSelectSQLBuilder.php @@ -0,0 +1,93 @@ +platform = $platform; + $this->forUpdateSQL = $forUpdateSQL; + $this->skipLockedSQL = $skipLockedSQL; + } + + /** @throws Exception */ + public function buildSQL(SelectQuery $query): string + { + $parts = ['SELECT']; + + if ($query->isDistinct()) { + $parts[] = 'DISTINCT'; + } + + $parts[] = implode(', ', $query->getColumns()); + + $from = $query->getFrom(); + + if (count($from) > 0) { + $parts[] = 'FROM ' . implode(', ', $from); + } + + $where = $query->getWhere(); + + if ($where !== null) { + $parts[] = 'WHERE ' . $where; + } + + $groupBy = $query->getGroupBy(); + + if (count($groupBy) > 0) { + $parts[] = 'GROUP BY ' . implode(', ', $groupBy); + } + + $having = $query->getHaving(); + + if ($having !== null) { + $parts[] = 'HAVING ' . $having; + } + + $orderBy = $query->getOrderBy(); + + if (count($orderBy) > 0) { + $parts[] = 'ORDER BY ' . implode(', ', $orderBy); + } + + $sql = implode(' ', $parts); + $limit = $query->getLimit(); + + if ($limit->isDefined()) { + $sql = $this->platform->modifyLimitQuery($sql, $limit->getMaxResults(), $limit->getFirstResult()); + } + + $forUpdate = $query->getForUpdate(); + + if ($forUpdate !== null) { + if ($this->forUpdateSQL === null) { + throw Exception::notSupported('FOR UPDATE'); + } + + $sql .= ' ' . $this->forUpdateSQL; + + if ($forUpdate->shouldSkipLocked()) { + if ($this->skipLockedSQL === null) { + throw Exception::notSupported('SKIP LOCKED'); + } + + $sql .= ' ' . $this->skipLockedSQL; + } + } + + return $sql; + } +} diff --git a/src/SQL/Builder/SelectSQLBuilder.php b/src/SQL/Builder/SelectSQLBuilder.php new file mode 100644 index 00000000000..ddbf73c03ba --- /dev/null +++ b/src/SQL/Builder/SelectSQLBuilder.php @@ -0,0 +1,12 @@ +connection->getDatabasePlatform(); + + if ($platform instanceof DB2Platform) { + self::markTestSkipped('Skipping on IBM DB2'); + } + + if ($platform instanceof MySQLPlatform) { + if ($platform instanceof MariaDBPlatform) { + if (! $platform instanceof MariaDb1060Platform) { + self::markTestSkipped('Skipping on MariaDB older than 10.6'); + } + } elseif (! $platform instanceof MySQL80Platform) { + self::markTestSkipped('Skipping on MySQL older than 8.0'); + } + } + + if ($platform instanceof PostgreSQLPlatform && ! $platform instanceof PostgreSQL100Platform) { + self::markTestSkipped('Skipping on PostgreSQL older than 10.0'); + } + + if ($platform instanceof SqlitePlatform) { + self::markTestSkipped('Skipping on SQLite'); + } + + if (TestUtil::isDriverOneOf('oci8')) { + // DBAL uses oci_connect() which won't necessarily start a new session, and there is + // no API to make it use oci_new_connect(). The feature is still covered via pdo_oci. + self::markTestSkipped('Skipping on oci8'); + } + + $table = new Table('users'); + $table->addColumn('id', Types::INTEGER); + $table->setPrimaryKey(['id']); + $this->dropAndCreateTable($table); + $this->connection->insert('users', ['id' => 1]); + $this->connection->insert('users', ['id' => 2]); + + $qb1 = new QueryBuilder($this->connection); + $qb1->select('id') + ->from('users') + ->where('id = 1') + ->forUpdate(); + + $this->connection->beginTransaction(); + + self::assertEquals([1], $qb1->fetchFirstColumn()); + + $connection2 = TestUtil::getConnection(); + + $qb2 = new QueryBuilder($connection2); + $qb2->select('id') + ->from('users') + ->orderBy('id') + ->forUpdate(true); + + self::assertEquals([2], $qb2->fetchFirstColumn()); + + $this->connection->commit(); + + self::assertEquals([1, 2], $qb2->fetchFirstColumn()); + } +} diff --git a/tests/Query/QueryBuilderTest.php b/tests/Query/QueryBuilderTest.php index 76fe76bdb7f..ce47330d44e 100644 --- a/tests/Query/QueryBuilderTest.php +++ b/tests/Query/QueryBuilderTest.php @@ -6,10 +6,12 @@ use Doctrine\DBAL\Cache\QueryCacheProfile; use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Query\Expression\ExpressionBuilder; use Doctrine\DBAL\Query\QueryBuilder; use Doctrine\DBAL\Query\QueryException; use Doctrine\DBAL\Result; +use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Doctrine\Deprecations\PHPUnit\VerifyDeprecations; @@ -31,9 +33,15 @@ protected function setUp(): void $expressionBuilder = new ExpressionBuilder($this->conn); - $this->conn->expects(self::any()) - ->method('getExpressionBuilder') - ->willReturn($expressionBuilder); + $this->conn->method('getExpressionBuilder') + ->willReturn($expressionBuilder); + + $platform = $this->createMock(AbstractPlatform::class); + $platform->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder($platform, null, null)); + + $this->conn->method('getDatabasePlatform') + ->willReturn($platform); } public function testSimpleSelectWithoutFrom(): void