diff --git a/UPGRADE.md b/UPGRADE.md index 06e70fdcd3c..73282a807a4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,11 @@ awareness about deprecated code. # Upgrade to 3.8 +## Deprecated lock-related `AbstractPlatform` methods + +The usage of `AbstractPlatform::getReadLockSQL()`, `::getWriteLockSQL()` and `::getForUpdateSQL` is deprecated as this +API is not portable. Use `QueryBuilder::forUpdate()` as a replacement for the latter. + ## Deprecated `AbstractMySQLPlatform` methods * `AbstractMySQLPlatform::getColumnTypeSQLSnippets()` has been deprecated diff --git a/psalm.xml.dist b/psalm.xml.dist index 0eb1e5ced86..ba2e28f574d 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -515,6 +515,9 @@ + + + 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 bc409bdf4cb..8651b82783d 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; @@ -541,6 +543,11 @@ protected function _getCreateTableSQL($name, array $columns, array $options = [] return $sql; } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, 'FOR UPDATE', null); + } + /** * {@inheritDoc} * diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index eba3aefc890..c77882a1a53 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; @@ -1758,10 +1760,19 @@ abstract public function getCurrentDatabaseExpression(): string; /** * Returns the FOR UPDATE expression. * + * @deprecated This API is not portable. Use {@link QueryBuilder::forUpdate()}` instead. + * * @return string */ public function getForUpdateSQL() { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/6191', + '%s is deprecated as non-portable.', + __METHOD__, + ); + return 'FOR UPDATE'; } @@ -1793,10 +1804,19 @@ public function appendLockHint(string $fromClause, int $lockMode): string * This defaults to the ANSI SQL "FOR UPDATE", which is an exclusive lock (Write). Some database * vendors allow to lighten this constraint up to be a real read lock. * + * @deprecated This API is not portable. + * * @return string */ public function getReadLockSQL() { + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/6191', + '%s is deprecated as non-portable.', + __METHOD__, + ); + return $this->getForUpdateSQL(); } @@ -1805,10 +1825,19 @@ public function getReadLockSQL() * * The semantics of this lock mode should equal the SELECT .. FOR UPDATE of the ANSI SQL standard. * + * @deprecated This API is not portable. + * * @return string */ public function getWriteLockSQL() { + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/6191', + '%s is deprecated as non-portable.', + __METHOD__, + ); + return $this->getForUpdateSQL(); } @@ -2052,6 +2081,11 @@ public function getCreateTableSQL(Table $table, $createFlags = self::CREATE_INDE ); } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, 'FOR UPDATE', 'SKIP LOCKED'); + } + /** * @internal * diff --git a/src/Platforms/DB2Platform.php b/src/Platforms/DB2Platform.php index b203ce8a052..64fabc32d0a 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,8 +976,15 @@ public function prefersIdentityColumns() return true; } + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, 'WITH RR USE AND KEEP UPDATE LOCKS', null); + } + /** * {@inheritDoc} + * + * @deprecated This API is not portable. */ public function getForUpdateSQL() { 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 @@ +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->getConflictResolutionMode() === ConflictResolutionMode::SKIP_LOCKED) { + $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..c8cd20b561c 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} */ @@ -1610,6 +1617,8 @@ public function appendLockHint(string $fromClause, int $lockMode): string /** * {@inheritDoc} + * + * @deprecated This API is not portable. */ public function getForUpdateSQL() { diff --git a/src/Platforms/SqlitePlatform.php b/src/Platforms/SqlitePlatform.php index 5acefc5c82f..d951662ae10 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,12 @@ public function getCurrentDatabaseExpression(): string return "'main'"; } + /** @link https://www2.sqlite.org/cvstrac/wiki?p=UnsupportedSql */ + public function createSelectSQLBuilder(): SelectSQLBuilder + { + return new DefaultSelectSQLBuilder($this, null, null); + } + /** * {@inheritDoc} */ @@ -734,6 +742,8 @@ public static function udfLocate($str, $substr, $offset = 0) /** * {@inheritDoc} + * + * @deprecated This API is not portable. */ public function getForUpdateSQL() { diff --git a/src/Query/ForUpdate.php b/src/Query/ForUpdate.php new file mode 100644 index 00000000000..fe54df909ee --- /dev/null +++ b/src/Query/ForUpdate.php @@ -0,0 +1,21 @@ +conflictResolutionMode = $conflictResolutionMode; + } + + public function getConflictResolutionMode(): int + { + return $this->conflictResolutionMode; + } +} diff --git a/src/Query/ForUpdate/ConflictResolutionMode.php b/src/Query/ForUpdate/ConflictResolutionMode.php new file mode 100644 index 00000000000..f968f7b941d --- /dev/null +++ b/src/Query/ForUpdate/ConflictResolutionMode.php @@ -0,0 +1,27 @@ +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 c4d67cf5d39..6799c12c5e2 100644 --- a/src/Query/QueryBuilder.php +++ b/src/Query/QueryBuilder.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\Expression\CompositeExpression; use Doctrine\DBAL\Query\Expression\ExpressionBuilder; +use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; use Doctrine\DBAL\Result; use Doctrine\DBAL\Statement; use Doctrine\DBAL\Types\Type; @@ -70,16 +71,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, ]; /** @@ -591,6 +593,20 @@ public function getMaxResults() return $this->maxResults; } + /** + * Locks the queried rows for a subsequent update. + * + * @return $this + */ + public function forUpdate(int $conflictResolutionMode = ConflictResolutionMode::ORDINARY): self + { + $this->state = self::STATE_DIRTY; + + $this->sqlParts['for_update'] = new ForUpdate($conflictResolutionMode); + + return $this; + } + /** * Either appends to or replaces a single, generic query part. * @@ -1475,27 +1491,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; } /** @@ -1542,11 +1555,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..ed9a7bc2dfb --- /dev/null +++ b/src/Query/SelectQuery.php @@ -0,0 +1,107 @@ +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..9dcb7a197fa --- /dev/null +++ b/src/SQL/Builder/DefaultSelectSQLBuilder.php @@ -0,0 +1,95 @@ +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->getConflictResolutionMode() === ConflictResolutionMode::SKIP_LOCKED) { + 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 @@ +addColumn('id', Types::INTEGER); + $table->setPrimaryKey(['id']); + + $this->dropAndCreateTable($table); + + $this->connection->insert('for_update', ['id' => 1]); + $this->connection->insert('for_update', ['id' => 2]); + } + + protected function tearDown(): void + { + if (! $this->connection->isTransactionActive()) { + return; + } + + $this->connection->rollBack(); + } + + public function testForUpdateOrdinary(): void + { + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof SqlitePlatform) { + self::markTestSkipped('Skipping on SQLite'); + } + + $qb1 = $this->connection->createQueryBuilder(); + $qb1->select('id') + ->from('for_update') + ->forUpdate(); + + self::assertEquals([1, 2], $qb1->fetchFirstColumn()); + } + + public function testForUpdateSkipLockedWhenSupported(): void + { + if (! $this->platformSupportsSkipLocked()) { + self::markTestSkipped('The database platform does not support SKIP LOCKED.'); + } + + $qb1 = $this->connection->createQueryBuilder(); + $qb1->select('id') + ->from('for_update') + ->where('id = 1') + ->forUpdate(); + + $this->connection->beginTransaction(); + + self::assertEquals([1], $qb1->fetchFirstColumn()); + + $params = TestUtil::getConnectionParams(); + + if (TestUtil::isDriverOneOf('oci8')) { + $params['driverOptions']['exclusive'] = true; + } + + $connection2 = DriverManager::getConnection($params); + + $qb2 = $connection2->createQueryBuilder(); + $qb2->select('id') + ->from('for_update') + ->orderBy('id') + ->forUpdate(ConflictResolutionMode::SKIP_LOCKED); + + self::assertEquals([2], $qb2->fetchFirstColumn()); + } + + public function testForUpdateSkipLockedWhenNotSupported(): void + { + if ($this->platformSupportsSkipLocked()) { + self::markTestSkipped('The database platform supports SKIP LOCKED.'); + } + + $qb = $this->connection->createQueryBuilder(); + $qb->select('id') + ->from('for_update') + ->forUpdate(ConflictResolutionMode::SKIP_LOCKED); + + self::expectException(Exception::class); + $qb->executeQuery(); + } + + private function platformSupportsSkipLocked(): bool + { + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof DB2Platform) { + return false; + } + + if ($platform instanceof MySQLPlatform) { + if ($platform instanceof MariaDBPlatform) { + if (! $platform instanceof MariaDb1060Platform) { + return false; + } + } elseif (! $platform instanceof MySQL80Platform) { + return false; + } + } + + if ($platform instanceof PostgreSQLPlatform && ! $platform instanceof PostgreSQL100Platform) { + return false; + } + + return ! $platform instanceof SqlitePlatform; + } +} diff --git a/tests/Query/QueryBuilderTest.php b/tests/Query/QueryBuilderTest.php index ba774b748aa..a9d488bfc05 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