diff --git a/UPGRADE.md b/UPGRADE.md index 18a4bd192ea..6da5e0be57c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -733,6 +733,15 @@ Use `toIterable()` instead. # Upgrade to 2.20 +## Add `Doctrine\ORM\Query\OutputWalker` interface, deprecate `Doctrine\ORM\Query\SqlWalker::getExecutor()` + +Output walkers should implement the new `\Doctrine\ORM\Query\OutputWalker` interface and create +`Doctrine\ORM\Query\Exec\SqlFinalizer` instances instead of `Doctrine\ORM\Query\Exec\AbstractSqlExecutor`s. +The output walker must not base its workings on the query `firstResult`/`maxResult` values, so that the +`SqlFinalizer` can be kept in the query cache and used regardless of the actual `firstResult`/`maxResult` values. +Any operation dependent on `firstResult`/`maxResult` should take place within the `SqlFinalizer::createExecutor()` +method. Details can be found at https://github.com/doctrine/orm/pull/11188. + ## Explictly forbid property hooks Property hooks are not supported yet by Doctrine ORM. Until support is added, @@ -741,10 +750,16 @@ change in behavior. Progress on this is tracked at https://github.com/doctrine/orm/issues/11624 . -## PARTIAL DQL syntax is undeprecated for non-object hydration +## PARTIAL DQL syntax is undeprecated + +Use of the PARTIAL keyword is not deprecated anymore in DQL, because we will be +able to support PARTIAL objects with PHP 8.4 Lazy Objects and +Symfony/VarExporter in a better way. When we decided to remove this feature +these two abstractions did not exist yet. -Use of the PARTIAL keyword is not deprecated anymore in DQL when used with a hydrator -that is not creating entities, such as the ArrayHydrator. +WARNING: If you want to upgrade to 3.x and still use PARTIAL keyword in DQL +with array or object hydrators, then you have to directly migrate to ORM 3.3.x or higher. +PARTIAL keyword in DQL is not available in 3.0, 3.1 and 3.2 of ORM. ## Deprecate `\Doctrine\ORM\Query\Parser::setCustomOutputTreeWalker()` diff --git a/docs/en/_theme b/docs/en/_theme deleted file mode 160000 index 6f1bc8bead1..00000000000 --- a/docs/en/_theme +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6f1bc8bead17b8032389659c0b071d00f2c58328 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index b83ae43a889..886493223c9 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -212,14 +212,14 @@ - - - - + + + + @@ -923,6 +923,9 @@ + + + @@ -1113,6 +1116,12 @@ + + getSqlStatements()]]> + + + + diff --git a/src/EntityManager.php b/src/EntityManager.php index 4e1dfaf5816..eb5a123d0b6 100644 --- a/src/EntityManager.php +++ b/src/EntityManager.php @@ -24,7 +24,6 @@ use Doctrine\ORM\Query\FilterCollection; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\Repository\RepositoryFactory; -use Throwable; use function array_keys; use function is_array; @@ -178,18 +177,24 @@ public function wrapInTransaction(callable $func): mixed { $this->conn->beginTransaction(); + $successful = false; + try { $return = $func($this); $this->flush(); $this->conn->commit(); - return $return; - } catch (Throwable $e) { - $this->close(); - $this->conn->rollBack(); + $successful = true; - throw $e; + return $return; + } finally { + if (! $successful) { + $this->close(); + if ($this->conn->isTransactionActive()) { + $this->conn->rollBack(); + } + } } } diff --git a/src/Query.php b/src/Query.php index a869316d3e7..f258e5ecbf1 100644 --- a/src/Query.php +++ b/src/Query.php @@ -7,11 +7,14 @@ use Doctrine\DBAL\LockMode; use Doctrine\DBAL\Result; use Doctrine\DBAL\Types\Type; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\AST\DeleteStatement; use Doctrine\ORM\Query\AST\SelectStatement; use Doctrine\ORM\Query\AST\UpdateStatement; use Doctrine\ORM\Query\Exec\AbstractSqlExecutor; +use Doctrine\ORM\Query\Exec\SqlFinalizer; +use Doctrine\ORM\Query\OutputWalker; use Doctrine\ORM\Query\Parameter; use Doctrine\ORM\Query\ParameterTypeInferer; use Doctrine\ORM\Query\Parser; @@ -27,6 +30,7 @@ use function count; use function get_debug_type; use function in_array; +use function is_a; use function ksort; use function md5; use function reset; @@ -163,7 +167,7 @@ class Query extends AbstractQuery */ public function getSQL(): string|array { - return $this->parse()->getSqlExecutor()->getSqlStatements(); + return $this->getSqlExecutor()->getSqlStatements(); } /** @@ -242,7 +246,7 @@ private function parse(): ParserResult protected function _doExecute(): Result|int { - $executor = $this->parse()->getSqlExecutor(); + $executor = $this->getSqlExecutor(); if ($this->queryCacheProfile) { $executor->setQueryCacheProfile($this->queryCacheProfile); @@ -656,11 +660,31 @@ protected function getQueryCacheId(): string { ksort($this->hints); + if (! $this->hasHint(self::HINT_CUSTOM_OUTPUT_WALKER)) { + // Assume Parser will create the SqlOutputWalker; save is_a call, which might trigger a class load + $firstAndMaxResult = ''; + } else { + $outputWalkerClass = $this->getHint(self::HINT_CUSTOM_OUTPUT_WALKER); + if (is_a($outputWalkerClass, OutputWalker::class, true)) { + $firstAndMaxResult = ''; + } else { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/11188/', + 'Your output walker class %s should implement %s in order to provide a %s. This also means the output walker should not use the query firstResult/maxResult values, which should be read from the query by the SqlFinalizer only.', + $outputWalkerClass, + OutputWalker::class, + SqlFinalizer::class, + ); + $firstAndMaxResult = '&firstResult=' . $this->firstResult . '&maxResult=' . $this->maxResults; + } + } + return md5( $this->getDQL() . serialize($this->hints) . '&platform=' . get_debug_type($this->getEntityManager()->getConnection()->getDatabasePlatform()) . ($this->em->hasFilters() ? $this->em->getFilters()->getHash() : '') . - '&firstResult=' . $this->firstResult . '&maxResult=' . $this->maxResults . + $firstAndMaxResult . '&hydrationMode=' . $this->hydrationMode . '&types=' . serialize($this->parsedTypes) . 'DOCTRINE_QUERY_CACHE_SALT', ); } @@ -679,4 +703,9 @@ public function __clone() $this->state = self::STATE_DIRTY; } + + private function getSqlExecutor(): AbstractSqlExecutor + { + return $this->parse()->prepareSqlExecutor($this); + } } diff --git a/src/Query/Exec/FinalizedSelectExecutor.php b/src/Query/Exec/FinalizedSelectExecutor.php new file mode 100644 index 00000000000..872d42cb6c4 --- /dev/null +++ b/src/Query/Exec/FinalizedSelectExecutor.php @@ -0,0 +1,29 @@ +sqlStatements = $sql; + } + + /** + * {@inheritDoc} + */ + public function execute(Connection $conn, array $params, array $types): Result + { + return $conn->executeQuery($this->getSqlStatements(), $params, $types, $this->queryCacheProfile); + } +} diff --git a/src/Query/Exec/PreparedExecutorFinalizer.php b/src/Query/Exec/PreparedExecutorFinalizer.php new file mode 100644 index 00000000000..26161dba782 --- /dev/null +++ b/src/Query/Exec/PreparedExecutorFinalizer.php @@ -0,0 +1,27 @@ +executor = $exeutor; + } + + public function createExecutor(Query $query): AbstractSqlExecutor + { + return $this->executor; + } +} diff --git a/src/Query/Exec/SingleSelectSqlFinalizer.php b/src/Query/Exec/SingleSelectSqlFinalizer.php new file mode 100644 index 00000000000..ac31c0cde36 --- /dev/null +++ b/src/Query/Exec/SingleSelectSqlFinalizer.php @@ -0,0 +1,60 @@ +getEntityManager()->getConnection()->getDatabasePlatform(); + + $sql = $platform->modifyLimitQuery($this->sql, $query->getMaxResults(), $query->getFirstResult()); + + $lockMode = $query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE; + + if ($lockMode !== LockMode::NONE && $lockMode !== LockMode::OPTIMISTIC && $lockMode !== LockMode::PESSIMISTIC_READ && $lockMode !== LockMode::PESSIMISTIC_WRITE) { + throw QueryException::invalidLockMode(); + } + + if ($lockMode === LockMode::PESSIMISTIC_READ) { + $sql .= ' ' . $this->getReadLockSQL($platform); + } elseif ($lockMode === LockMode::PESSIMISTIC_WRITE) { + $sql .= ' ' . $this->getWriteLockSQL($platform); + } + + return $sql; + } + + /** @return FinalizedSelectExecutor */ + public function createExecutor(Query $query): AbstractSqlExecutor + { + return new FinalizedSelectExecutor($this->finalizeSql($query)); + } +} diff --git a/src/Query/Exec/SingleTableDeleteUpdateExecutor.php b/src/Query/Exec/SingleTableDeleteUpdateExecutor.php index 66696dbde52..721bb40ad1f 100644 --- a/src/Query/Exec/SingleTableDeleteUpdateExecutor.php +++ b/src/Query/Exec/SingleTableDeleteUpdateExecutor.php @@ -14,8 +14,6 @@ * that are mapped to a single table. * * @link www.doctrine-project.org - * - * @todo This is exactly the same as SingleSelectExecutor. Unify in SingleStatementExecutor. */ class SingleTableDeleteUpdateExecutor extends AbstractSqlExecutor { diff --git a/src/Query/Exec/SqlFinalizer.php b/src/Query/Exec/SqlFinalizer.php new file mode 100644 index 00000000000..cddad84e8a3 --- /dev/null +++ b/src/Query/Exec/SqlFinalizer.php @@ -0,0 +1,26 @@ +queryComponents = $treeWalkerChain->getQueryComponents(); } - $outputWalkerClass = $this->customOutputWalker ?: SqlWalker::class; + $outputWalkerClass = $this->customOutputWalker ?: SqlOutputWalker::class; $outputWalker = new $outputWalkerClass($this->query, $this->parserResult, $this->queryComponents); - // Assign an SQL executor to the parser result - $this->parserResult->setSqlExecutor($outputWalker->getExecutor($AST)); + if ($outputWalker instanceof OutputWalker) { + $finalizer = $outputWalker->getFinalizer($AST); + $this->parserResult->setSqlFinalizer($finalizer); + } else { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/11188/', + 'Your output walker class %s should implement %s in order to provide a %s. This also means the output walker should not use the query firstResult/maxResult values, which should be read from the query by the SqlFinalizer only.', + $outputWalkerClass, + OutputWalker::class, + SqlFinalizer::class, + ); + // @phpstan-ignore method.deprecated + $executor = $outputWalker->getExecutor($AST); + // @phpstan-ignore method.deprecated + $this->parserResult->setSqlExecutor($executor); + } return $this->parserResult; } diff --git a/src/Query/ParserResult.php b/src/Query/ParserResult.php index 8b5ee1f7ee5..7539e999ac3 100644 --- a/src/Query/ParserResult.php +++ b/src/Query/ParserResult.php @@ -4,7 +4,9 @@ namespace Doctrine\ORM\Query; +use Doctrine\ORM\Query; use Doctrine\ORM\Query\Exec\AbstractSqlExecutor; +use Doctrine\ORM\Query\Exec\SqlFinalizer; use LogicException; use function sprintf; @@ -22,6 +24,11 @@ class ParserResult */ private AbstractSqlExecutor|null $sqlExecutor = null; + /** + * The SQL executor used for executing the SQL. + */ + private SqlFinalizer|null $sqlFinalizer = null; + /** * The ResultSetMapping that describes how to map the SQL result set. */ @@ -63,6 +70,8 @@ public function setResultSetMapping(ResultSetMapping $rsm): void /** * Sets the SQL executor that should be used for this ParserResult. + * + * @deprecated */ public function setSqlExecutor(AbstractSqlExecutor $executor): void { @@ -71,6 +80,8 @@ public function setSqlExecutor(AbstractSqlExecutor $executor): void /** * Gets the SQL executor used by this ParserResult. + * + * @deprecated */ public function getSqlExecutor(): AbstractSqlExecutor { @@ -84,6 +95,24 @@ public function getSqlExecutor(): AbstractSqlExecutor return $this->sqlExecutor; } + public function setSqlFinalizer(SqlFinalizer $finalizer): void + { + $this->sqlFinalizer = $finalizer; + } + + public function prepareSqlExecutor(Query $query): AbstractSqlExecutor + { + if ($this->sqlFinalizer !== null) { + return $this->sqlFinalizer->createExecutor($query); + } + + if ($this->sqlExecutor !== null) { + return $this->sqlExecutor; + } + + throw new LogicException('This ParserResult lacks both the SqlFinalizer as well as the (legacy) SqlExecutor'); + } + /** * Adds a DQL to SQL parameter mapping. One DQL parameter name/position can map to * several SQL parameter positions. diff --git a/src/Query/SqlOutputWalker.php b/src/Query/SqlOutputWalker.php new file mode 100644 index 00000000000..96cf347fc6a --- /dev/null +++ b/src/Query/SqlOutputWalker.php @@ -0,0 +1,29 @@ +createSqlForFinalizer($AST)); + + case $AST instanceof AST\UpdateStatement: + return new PreparedExecutorFinalizer($this->createUpdateStatementExecutor($AST)); + + case $AST instanceof AST\DeleteStatement: + return new PreparedExecutorFinalizer($this->createDeleteStatementExecutor($AST)); + } + + throw new LogicException('Unexpected AST node type'); + } +} diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 46296e719e7..6215bcd852e 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -15,7 +15,6 @@ use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\Query; use Doctrine\ORM\Utility\HierarchyDiscriminatorResolver; -use Doctrine\ORM\Utility\LockSqlHelper; use Doctrine\ORM\Utility\PersisterHelper; use InvalidArgumentException; use LogicException; @@ -51,8 +50,6 @@ */ class SqlWalker { - use LockSqlHelper; - public const HINT_DISTINCT = 'doctrine.distinct'; /** @@ -235,23 +232,40 @@ public function setQueryComponent(string $dqlAlias, array $queryComponent): void /** * Gets an executor that can be used to execute the result of this walker. + * + * @deprecated Output walkers should no longer create the executor directly, but instead provide + * a SqlFinalizer by implementing the `OutputWalker` interface. Thus, this method is + * no longer needed and will be removed in 4.0. */ public function getExecutor(AST\SelectStatement|AST\UpdateStatement|AST\DeleteStatement $statement): Exec\AbstractSqlExecutor { return match (true) { - $statement instanceof AST\SelectStatement - => new Exec\SingleSelectExecutor($statement, $this), - $statement instanceof AST\UpdateStatement - => $this->em->getClassMetadata($statement->updateClause->abstractSchemaName)->isInheritanceTypeJoined() - ? new Exec\MultiTableUpdateExecutor($statement, $this) - : new Exec\SingleTableDeleteUpdateExecutor($statement, $this), - $statement instanceof AST\DeleteStatement - => $this->em->getClassMetadata($statement->deleteClause->abstractSchemaName)->isInheritanceTypeJoined() - ? new Exec\MultiTableDeleteExecutor($statement, $this) - : new Exec\SingleTableDeleteUpdateExecutor($statement, $this), + $statement instanceof AST\UpdateStatement => $this->createUpdateStatementExecutor($statement), + $statement instanceof AST\DeleteStatement => $this->createDeleteStatementExecutor($statement), + default => new Exec\SingleSelectExecutor($statement, $this), }; } + /** @psalm-internal Doctrine\ORM */ + protected function createUpdateStatementExecutor(AST\UpdateStatement $AST): Exec\AbstractSqlExecutor + { + $primaryClass = $this->em->getClassMetadata($AST->updateClause->abstractSchemaName); + + return $primaryClass->isInheritanceTypeJoined() + ? new Exec\MultiTableUpdateExecutor($AST, $this) + : new Exec\SingleTableDeleteUpdateExecutor($AST, $this); + } + + /** @psalm-internal Doctrine\ORM */ + protected function createDeleteStatementExecutor(AST\DeleteStatement $AST): Exec\AbstractSqlExecutor + { + $primaryClass = $this->em->getClassMetadata($AST->deleteClause->abstractSchemaName); + + return $primaryClass->isInheritanceTypeJoined() + ? new Exec\MultiTableDeleteExecutor($AST, $this) + : new Exec\SingleTableDeleteUpdateExecutor($AST, $this); + } + /** * Generates a unique, short SQL table alias. */ @@ -479,10 +493,15 @@ private function generateFilterConditionSQL( */ public function walkSelectStatement(AST\SelectStatement $selectStatement): string { - $limit = $this->query->getMaxResults(); - $offset = $this->query->getFirstResult(); - $lockMode = $this->query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE; - $sql = $this->walkSelectClause($selectStatement->selectClause) + $sql = $this->createSqlForFinalizer($selectStatement); + $finalizer = new Exec\SingleSelectSqlFinalizer($sql); + + return $finalizer->finalizeSql($this->query); + } + + protected function createSqlForFinalizer(AST\SelectStatement $selectStatement): string + { + $sql = $this->walkSelectClause($selectStatement->selectClause) . $this->walkFromClause($selectStatement->fromClause) . $this->walkWhereClause($selectStatement->whereClause); @@ -503,31 +522,22 @@ public function walkSelectStatement(AST\SelectStatement $selectStatement): strin $sql .= ' ORDER BY ' . $orderBySql; } - $sql = $this->platform->modifyLimitQuery($sql, $limit, $offset); + $this->assertOptimisticLockingHasAllClassesVersioned(); - if ($lockMode === LockMode::NONE) { - return $sql; - } - - if ($lockMode === LockMode::PESSIMISTIC_READ) { - return $sql . ' ' . $this->getReadLockSQL($this->platform); - } - - if ($lockMode === LockMode::PESSIMISTIC_WRITE) { - return $sql . ' ' . $this->getWriteLockSQL($this->platform); - } + return $sql; + } - if ($lockMode !== LockMode::OPTIMISTIC) { - throw QueryException::invalidLockMode(); - } + private function assertOptimisticLockingHasAllClassesVersioned(): void + { + $lockMode = $this->query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE; - foreach ($this->selectedClasses as $selectedClass) { - if (! $selectedClass['class']->isVersioned) { - throw OptimisticLockException::lockFailed($selectedClass['class']->name); + if ($lockMode === LockMode::OPTIMISTIC) { + foreach ($this->selectedClasses as $selectedClass) { + if (! $selectedClass['class']->isVersioned) { + throw OptimisticLockException::lockFailed($selectedClass['class']->name); + } } } - - return $sql; } /** diff --git a/src/Tools/Pagination/CountOutputWalker.php b/src/Tools/Pagination/CountOutputWalker.php index c7f31dbf628..35f7d051ecf 100644 --- a/src/Tools/Pagination/CountOutputWalker.php +++ b/src/Tools/Pagination/CountOutputWalker.php @@ -11,7 +11,7 @@ use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\ParserResult; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\SqlOutputWalker; use RuntimeException; use function array_diff; @@ -37,7 +37,7 @@ * * @psalm-import-type QueryComponent from Parser */ -class CountOutputWalker extends SqlWalker +class CountOutputWalker extends SqlOutputWalker { private readonly AbstractPlatform $platform; private readonly ResultSetMapping $rsm; @@ -53,13 +53,13 @@ public function __construct(Query $query, ParserResult $parserResult, array $que parent::__construct($query, $parserResult, $queryComponents); } - public function walkSelectStatement(SelectStatement $selectStatement): string + protected function createSqlForFinalizer(SelectStatement $selectStatement): string { if ($this->platform instanceof SQLServerPlatform) { $selectStatement->orderByClause = null; } - $sql = parent::walkSelectStatement($selectStatement); + $sql = parent::createSqlForFinalizer($selectStatement); if ($selectStatement->groupByClause) { return sprintf( diff --git a/src/Tools/Pagination/LimitSubqueryOutputWalker.php b/src/Tools/Pagination/LimitSubqueryOutputWalker.php index 8bbc44c21a1..5cb65e7a993 100644 --- a/src/Tools/Pagination/LimitSubqueryOutputWalker.php +++ b/src/Tools/Pagination/LimitSubqueryOutputWalker.php @@ -13,16 +13,20 @@ use Doctrine\ORM\Mapping\QuoteStrategy; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\AST; use Doctrine\ORM\Query\AST\OrderByClause; use Doctrine\ORM\Query\AST\PathExpression; use Doctrine\ORM\Query\AST\SelectExpression; use Doctrine\ORM\Query\AST\SelectStatement; use Doctrine\ORM\Query\AST\Subselect; +use Doctrine\ORM\Query\Exec\SingleSelectSqlFinalizer; +use Doctrine\ORM\Query\Exec\SqlFinalizer; use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\ParserResult; use Doctrine\ORM\Query\QueryException; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\SqlOutputWalker; +use LogicException; use RuntimeException; use function array_diff; @@ -50,7 +54,7 @@ * * @psalm-import-type QueryComponent from Parser */ -class LimitSubqueryOutputWalker extends SqlWalker +class LimitSubqueryOutputWalker extends SqlOutputWalker { private const ORDER_BY_PATH_EXPRESSION = '/(?platform = $query->getEntityManager()->getConnection()->getDatabasePlatform(); $this->rsm = $parserResult->getResultSetMapping(); + $query = clone $query; + // Reset limit and offset $this->firstResult = $query->getFirstResult(); $this->maxResults = $query->getMaxResults(); @@ -139,11 +145,28 @@ private function rebuildOrderByForRowNumber(SelectStatement $AST): void public function walkSelectStatement(SelectStatement $selectStatement): string { + $sqlFinalizer = $this->getFinalizer($selectStatement); + + $query = $this->getQuery(); + + $abstractSqlExecutor = $sqlFinalizer->createExecutor($query); + + return $abstractSqlExecutor->getSqlStatements(); + } + + public function getFinalizer(AST\DeleteStatement|AST\UpdateStatement|AST\SelectStatement $AST): SqlFinalizer + { + if (! $AST instanceof SelectStatement) { + throw new LogicException(self::class . ' is to be used on SelectStatements only'); + } + if ($this->platformSupportsRowNumber()) { - return $this->walkSelectStatementWithRowNumber($selectStatement); + $sql = $this->createSqlWithRowNumber($AST); + } else { + $sql = $this->createSqlWithoutRowNumber($AST); } - return $this->walkSelectStatementWithoutRowNumber($selectStatement); + return new SingleSelectSqlFinalizer($sql); } /** @@ -153,6 +176,16 @@ public function walkSelectStatement(SelectStatement $selectStatement): string * @throws RuntimeException */ public function walkSelectStatementWithRowNumber(SelectStatement $AST): string + { + // Apply the limit and offset. + return $this->platform->modifyLimitQuery( + $this->createSqlWithRowNumber($AST), + $this->maxResults, + $this->firstResult, + ); + } + + private function createSqlWithRowNumber(SelectStatement $AST): string { $hasOrderBy = false; $outerOrderBy = ' ORDER BY dctrn_minrownum ASC'; @@ -182,13 +215,6 @@ public function walkSelectStatementWithRowNumber(SelectStatement $AST): string $sql .= $orderGroupBy . $outerOrderBy; } - // Apply the limit and offset. - $sql = $this->platform->modifyLimitQuery( - $sql, - $this->maxResults, - $this->firstResult, - ); - // Add the columns to the ResultSetMapping. It's not really nice but // it works. Preferably I'd clear the RSM or simply create a new one // but that is not possible from inside the output walker, so we dirty @@ -207,6 +233,16 @@ public function walkSelectStatementWithRowNumber(SelectStatement $AST): string * @throws RuntimeException */ public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, bool $addMissingItemsFromOrderByToSelect = true): string + { + // Apply the limit and offset. + return $this->platform->modifyLimitQuery( + $this->createSqlWithoutRowNumber($AST, $addMissingItemsFromOrderByToSelect), + $this->maxResults, + $this->firstResult, + ); + } + + private function createSqlWithoutRowNumber(SelectStatement $AST, bool $addMissingItemsFromOrderByToSelect = true): string { // We don't want to call this recursively! if ($AST->orderByClause instanceof OrderByClause && $addMissingItemsFromOrderByToSelect) { @@ -235,13 +271,6 @@ public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, bool $ // https://github.com/doctrine/orm/issues/2630 $sql = $this->preserveSqlOrdering($sqlIdentifier, $innerSql, $sql, $orderByClause); - // Apply the limit and offset. - $sql = $this->platform->modifyLimitQuery( - $sql, - $this->maxResults, - $this->firstResult, - ); - // Add the columns to the ResultSetMapping. It's not really nice but // it works. Preferably I'd clear the RSM or simply create a new one // but that is not possible from inside the output walker, so we dirty diff --git a/src/Tools/Pagination/RootTypeWalker.php b/src/Tools/Pagination/RootTypeWalker.php index f630ee14dea..82d52c2f4c4 100644 --- a/src/Tools/Pagination/RootTypeWalker.php +++ b/src/Tools/Pagination/RootTypeWalker.php @@ -5,7 +5,10 @@ namespace Doctrine\ORM\Tools\Pagination; use Doctrine\ORM\Query\AST; -use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\Exec\FinalizedSelectExecutor; +use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer; +use Doctrine\ORM\Query\Exec\SqlFinalizer; +use Doctrine\ORM\Query\SqlOutputWalker; use Doctrine\ORM\Utility\PersisterHelper; use RuntimeException; @@ -22,7 +25,7 @@ * Returning the type instead of a "real" SQL statement is a slight hack. However, it has the * benefit that the DQL -> root entity id type resolution can be cached in the query cache. */ -final class RootTypeWalker extends SqlWalker +final class RootTypeWalker extends SqlOutputWalker { public function walkSelectStatement(AST\SelectStatement $selectStatement): string { @@ -45,4 +48,13 @@ public function walkSelectStatement(AST\SelectStatement $selectStatement): strin ->getEntityManager(), )[0]; } + + public function getFinalizer(AST\DeleteStatement|AST\UpdateStatement|AST\SelectStatement $AST): SqlFinalizer + { + if (! $AST instanceof AST\SelectStatement) { + throw new RuntimeException(self::class . ' is to be used on SelectStatements only'); + } + + return new PreparedExecutorFinalizer(new FinalizedSelectExecutor($this->walkSelectStatement($AST))); + } } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index e26602ef92a..4e0cf6ce26b 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -51,7 +51,6 @@ use InvalidArgumentException; use RuntimeException; use Stringable; -use Throwable; use UnexpectedValueException; use function array_chunk; @@ -381,6 +380,8 @@ public function commit(): void $conn = $this->em->getConnection(); $conn->beginTransaction(); + $successful = false; + try { // Collection deletions (deletions of complete collections) foreach ($this->collectionDeletions as $collectionToDelete) { @@ -438,16 +439,18 @@ public function commit(): void if ($commitFailed) { throw new OptimisticLockException('Commit failed', null, $e ?? null); } - } catch (Throwable $e) { - $this->em->close(); - if ($conn->isTransactionActive()) { - $conn->rollBack(); - } + $successful = true; + } finally { + if (! $successful) { + $this->em->close(); - $this->afterTransactionRolledBack(); + if ($conn->isTransactionActive()) { + $conn->rollBack(); + } - throw $e; + $this->afterTransactionRolledBack(); + } } $this->afterTransactionComplete(); diff --git a/tests/Tests/Mocks/NullSqlWalker.php b/tests/Tests/Mocks/NullSqlWalker.php index 3e940e08e59..f94d1705a60 100644 --- a/tests/Tests/Mocks/NullSqlWalker.php +++ b/tests/Tests/Mocks/NullSqlWalker.php @@ -7,12 +7,14 @@ use Doctrine\DBAL\Connection; use Doctrine\ORM\Query\AST; use Doctrine\ORM\Query\Exec\AbstractSqlExecutor; -use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer; +use Doctrine\ORM\Query\Exec\SqlFinalizer; +use Doctrine\ORM\Query\SqlOutputWalker; /** * SqlWalker implementation that does not produce SQL. */ -final class NullSqlWalker extends SqlWalker +final class NullSqlWalker extends SqlOutputWalker { public function walkSelectStatement(AST\SelectStatement $selectStatement): string { @@ -29,13 +31,15 @@ public function walkDeleteStatement(AST\DeleteStatement $deleteStatement): strin return ''; } - public function getExecutor(AST\SelectStatement|AST\UpdateStatement|AST\DeleteStatement $statement): AbstractSqlExecutor + public function getFinalizer(AST\SelectStatement|AST\UpdateStatement|AST\DeleteStatement $statement): SqlFinalizer { - return new class extends AbstractSqlExecutor { - public function execute(Connection $conn, array $params, array $types): int - { - return 0; - } - }; + return new PreparedExecutorFinalizer( + new class extends AbstractSqlExecutor { + public function execute(Connection $conn, array $params, array $types): int + { + return 0; + } + }, + ); } } diff --git a/tests/Tests/ORM/EntityManagerTest.php b/tests/Tests/ORM/EntityManagerTest.php index 501f86550ce..0eab801622b 100644 --- a/tests/Tests/ORM/EntityManagerTest.php +++ b/tests/Tests/ORM/EntityManagerTest.php @@ -6,6 +6,7 @@ use Doctrine\Common\EventManager; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Driver; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; @@ -19,7 +20,9 @@ use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\OrmTestCase; +use Exception; use Generator; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use ReflectionProperty; @@ -207,4 +210,51 @@ public function clear(): void $em->resetLazyObject(); $this->assertTrue($em->isOpen()); } + + public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void + { + $entityManager = new EntityManagerMock(new class ([], $this->createMock(Driver::class)) extends Connection { + public function rollBack(): void + { + throw new Exception('Rollback exception'); + } + }); + + try { + $entityManager->wrapInTransaction(static function (): void { + throw new Exception('Original exception'); + }); + self::fail('Exception expected'); + } catch (Exception $e) { + self::assertSame('Rollback exception', $e->getMessage()); + self::assertNotNull($e->getPrevious()); + self::assertSame('Original exception', $e->getPrevious()->getMessage()); + } + } + + public function testItDoesNotAttemptToRollbackIfNoTransactionIsActive(): void + { + $entityManager = new EntityManagerMock( + new class ([], $this->createMock(Driver::class)) extends Connection { + public function commit(): void + { + throw new Exception('Commit exception that happens after doing the actual commit'); + } + + public function rollBack(): void + { + Assert::fail('Should not attempt to rollback if no transaction is active'); + } + + public function isTransactionActive(): bool + { + return false; + } + }, + ); + + $this->expectExceptionMessage('Commit exception'); + $entityManager->wrapInTransaction(static function (): void { + }); + } } diff --git a/tests/Tests/ORM/Functional/ParserResultSerializationTest.php b/tests/Tests/ORM/Functional/ParserResultSerializationTest.php index e927ba5af5f..6918bd8e50b 100644 --- a/tests/Tests/ORM/Functional/ParserResultSerializationTest.php +++ b/tests/Tests/ORM/Functional/ParserResultSerializationTest.php @@ -6,6 +6,8 @@ use Closure; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Exec\FinalizedSelectExecutor; +use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer; use Doctrine\ORM\Query\Exec\SingleSelectExecutor; use Doctrine\ORM\Query\ParserResult; use Doctrine\ORM\Query\ResultSetMapping; @@ -32,18 +34,37 @@ protected function setUp(): void /** @param Closure(ParserResult): ParserResult $toSerializedAndBack */ #[DataProvider('provideToSerializedAndBack')] - public function testSerializeParserResult(Closure $toSerializedAndBack): void + public function testSerializeParserResultForQueryWithSqlWalker(Closure $toSerializedAndBack): void { $query = $this->_em ->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyEmployee u WHERE u.name = :name'); + // Use the (legacy) SqlWalker which directly puts an SqlExecutor instance into the parser result + $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, Query\SqlWalker::class); + $parserResult = self::parseQuery($query); $unserialized = $toSerializedAndBack($parserResult); $this->assertInstanceOf(ParserResult::class, $unserialized); $this->assertInstanceOf(ResultSetMapping::class, $unserialized->getResultSetMapping()); $this->assertEquals(['name' => [0]], $unserialized->getParameterMappings()); - $this->assertInstanceOf(SingleSelectExecutor::class, $unserialized->getSqlExecutor()); + $this->assertNotNull($unserialized->prepareSqlExecutor($query)); + } + + /** @param Closure(ParserResult): ParserResult $toSerializedAndBack */ + #[DataProvider('provideToSerializedAndBack')] + public function testSerializeParserResultForQueryWithSqlOutputWalker(Closure $toSerializedAndBack): void + { + $query = $this->_em + ->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyEmployee u WHERE u.name = :name'); + + $parserResult = self::parseQuery($query); + $unserialized = $toSerializedAndBack($parserResult); + + $this->assertInstanceOf(ParserResult::class, $unserialized); + $this->assertInstanceOf(ResultSetMapping::class, $unserialized->getResultSetMapping()); + $this->assertEquals(['name' => [0]], $unserialized->getParameterMappings()); + $this->assertNotNull($unserialized->prepareSqlExecutor($query)); } /** @return Generator */ @@ -87,11 +108,12 @@ public static function provideSerializedSingleSelectResults(): Generator public function testSymfony44ProvidedData(): void { - $sqlExecutor = $this->createMock(SingleSelectExecutor::class); + $sqlExecutor = new FinalizedSelectExecutor('test'); + $sqlFinalizer = new PreparedExecutorFinalizer($sqlExecutor); $resultSetMapping = $this->createMock(ResultSetMapping::class); $parserResult = new ParserResult(); - $parserResult->setSqlExecutor($sqlExecutor); + $parserResult->setSqlFinalizer($sqlFinalizer); $parserResult->setResultSetMapping($resultSetMapping); $parserResult->addParameterMapping('name', 0); @@ -101,7 +123,7 @@ public function testSymfony44ProvidedData(): void $this->assertInstanceOf(ParserResult::class, $unserialized); $this->assertInstanceOf(ResultSetMapping::class, $unserialized->getResultSetMapping()); $this->assertEquals(['name' => [0]], $unserialized->getParameterMappings()); - $this->assertInstanceOf(SingleSelectExecutor::class, $unserialized->getSqlExecutor()); + $this->assertEquals($sqlExecutor, $unserialized->prepareSqlExecutor($this->createMock(Query::class))); } private static function parseQuery(Query $query): ParserResult diff --git a/tests/Tests/ORM/Functional/QueryCacheTest.php b/tests/Tests/ORM/Functional/QueryCacheTest.php index 1c1bc13764e..891a3ba18c7 100644 --- a/tests/Tests/ORM/Functional/QueryCacheTest.php +++ b/tests/Tests/ORM/Functional/QueryCacheTest.php @@ -44,7 +44,7 @@ public function testQueryCacheDependsOnHints(): array } #[Depends('testQueryCacheDependsOnHints')] - public function testQueryCacheDependsOnFirstResult(array $previous): void + public function testQueryCacheDoesNotDependOnFirstResultForDefaultOutputWalker(array $previous): void { [$query, $cache] = $previous; assert($query instanceof Query); @@ -56,11 +56,11 @@ public function testQueryCacheDependsOnFirstResult(array $previous): void $query->setMaxResults(9999); $query->getResult(); - self::assertCount($cacheCount + 1, $cache->getValues()); + self::assertCount($cacheCount, $cache->getValues()); } #[Depends('testQueryCacheDependsOnHints')] - public function testQueryCacheDependsOnMaxResults(array $previous): void + public function testQueryCacheDoesNotDependOnMaxResultsForDefaultOutputWalker(array $previous): void { [$query, $cache] = $previous; assert($query instanceof Query); @@ -71,7 +71,7 @@ public function testQueryCacheDependsOnMaxResults(array $previous): void $query->setMaxResults(10); $query->getResult(); - self::assertCount($cacheCount + 1, $cache->getValues()); + self::assertCount($cacheCount, $cache->getValues()); } #[Depends('testQueryCacheDependsOnHints')] diff --git a/tests/Tests/ORM/Functional/Ticket/GH11112Test.php b/tests/Tests/ORM/Functional/Ticket/GH11112Test.php new file mode 100644 index 00000000000..d5a11cda6bf --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11112Test.php @@ -0,0 +1,79 @@ +useModelSet('cms'); + self::$queryCache = new ArrayAdapter(); + + parent::setUp(); + } + + public function testSimpleQueryHasLimitAndOffsetApplied(): void + { + $platform = $this->_em->getConnection()->getDatabasePlatform(); + $query = $this->_em->createQuery('SELECT u FROM ' . CmsUser::class . ' u'); + $originalSql = $query->getSQL(); + + $query->setMaxResults(10); + $query->setFirstResult(20); + $sqlMax10First20 = $query->getSQL(); + + $query->setMaxResults(30); + $query->setFirstResult(40); + $sqlMax30First40 = $query->getSQL(); + + // The SQL is platform specific and may even be something with outer SELECTS being added. So, + // derive the expected value at runtime through the platform. + self::assertSame($platform->modifyLimitQuery($originalSql, 10, 20), $sqlMax10First20); + self::assertSame($platform->modifyLimitQuery($originalSql, 30, 40), $sqlMax30First40); + + $cacheEntries = self::$queryCache->getValues(); + self::assertCount(1, $cacheEntries); + } + + public function testSubqueryLimitAndOffsetAreIgnored(): void + { + // Not sure what to do about this test. Basically, I want to make sure that + // firstResult/maxResult for subqueries are not relevant, they do not make it + // into the final query at all. That would give us the guarantee that the + // "sql finalizer" step is sufficient for the final, "outer" query and we + // do not need to run finalizers for the subqueries. + + // This DQL/query makes no sense, it's just about creating a subquery in the first place + $queryBuilder = $this->_em->createQueryBuilder(); + $queryBuilder + ->select('o') + ->from(CmsUser::class, 'o') + ->where($queryBuilder->expr()->exists( + $this->_em->createQueryBuilder() + ->select('u') + ->from(CmsUser::class, 'u') + ->setFirstResult(10) + ->setMaxResults(20), + )); + + $query = $queryBuilder->getQuery(); + $originalSql = $query->getSQL(); + + $clone = clone $query; + $clone->setFirstResult(24); + $clone->setMaxResults(42); + $limitedSql = $clone->getSQL(); + + $platform = $this->_em->getConnection()->getDatabasePlatform(); + + // The SQL is platform specific and may even be something with outer SELECTS being added. So, + // derive the expected value at runtime through the platform. + self::assertSame($platform->modifyLimitQuery($originalSql, 42, 24), $limitedSql); + } +} diff --git a/tests/Tests/ORM/Query/CustomTreeWalkersTest.php b/tests/Tests/ORM/Query/CustomTreeWalkersTest.php index acd9d22ae32..83e001fbdcd 100644 --- a/tests/Tests/ORM/Query/CustomTreeWalkersTest.php +++ b/tests/Tests/ORM/Query/CustomTreeWalkersTest.php @@ -15,6 +15,7 @@ use Doctrine\ORM\Query\AST\SelectStatement; use Doctrine\ORM\Query\AST\WhereClause; use Doctrine\ORM\Query\QueryException; +use Doctrine\ORM\Query\SqlOutputWalker; use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\TreeWalker; use Doctrine\ORM\Query\TreeWalkerAdapter; @@ -118,15 +119,13 @@ public function testSupportsSeveralHintsQueries(): void } } -class AddUnknownQueryComponentWalker extends SqlWalker +class AddUnknownQueryComponentWalker extends SqlOutputWalker { - public function walkSelectStatement(SelectStatement $selectStatement): string + protected function createSqlForFinalizer(SelectStatement $selectStatement): string { - $sql = parent::walkSelectStatement($selectStatement); - $this->setQueryComponent('x', []); - return $sql; + return parent::createSqlForFinalizer($selectStatement); } } diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php index 0f5bac25b72..a6275d08b3c 100644 --- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Query; use Doctrine\ORM\Tools\Pagination\LimitSubqueryOutputWalker; use PHPUnit\Framework\Attributes\Group; +use Symfony\Component\Cache\Adapter\ArrayAdapter; final class LimitSubqueryOutputWalkerTest extends PaginationTestCase { @@ -273,6 +274,28 @@ public function testLimitSubqueryOrderBySubSelectOrderByExpressionOracle(): void ); } + public function testParsingQueryWithDifferentLimitOffsetValuesTakesOnlyOneCacheEntry(): void + { + $queryCache = new ArrayAdapter(); + $this->entityManager->getConfiguration()->setQueryCache($queryCache); + + $query = $this->createQuery('SELECT p, c, a FROM Doctrine\Tests\ORM\Tools\Pagination\MyBlogPost p JOIN p.category c JOIN p.author a'); + + self::assertSame( + 'SELECT DISTINCT id_0 FROM (SELECT m0_.id AS id_0, m0_.title AS title_1, c1_.id AS id_2, a2_.id AS id_3, a2_.name AS name_4, m0_.author_id AS author_id_5, m0_.category_id AS category_id_6 FROM MyBlogPost m0_ INNER JOIN Category c1_ ON m0_.category_id = c1_.id INNER JOIN Author a2_ ON m0_.author_id = a2_.id) dctrn_result LIMIT 20 OFFSET 10', + $query->getSQL(), + ); + + $query->setFirstResult(30)->setMaxResults(40); + + self::assertSame( + 'SELECT DISTINCT id_0 FROM (SELECT m0_.id AS id_0, m0_.title AS title_1, c1_.id AS id_2, a2_.id AS id_3, a2_.name AS name_4, m0_.author_id AS author_id_5, m0_.category_id AS category_id_6 FROM MyBlogPost m0_ INNER JOIN Category c1_ ON m0_.category_id = c1_.id INNER JOIN Author a2_ ON m0_.author_id = a2_.id) dctrn_result LIMIT 40 OFFSET 30', + $query->getSQL(), + ); + + self::assertCount(1, $queryCache->getValues()); + } + private function createQuery(string $dql): Query { $query = $this->entityManager->createQuery($dql); diff --git a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php b/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php index 41e3981e3b2..fcd8a9b2aa7 100644 --- a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php +++ b/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php @@ -94,7 +94,8 @@ public function testExtraParametersAreStrippedWhenWalkerRemovingOriginalSelectEl public function testPaginatorNotCaringAboutExtraParametersWithoutOutputWalkers(): void { - $this->connection->expects(self::exactly(3))->method('executeQuery'); + $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock(); + $this->connection->expects(self::exactly(3))->method('executeQuery')->willReturn($result); $this->createPaginatorWithExtraParametersWithoutOutputWalkers([])->count(); $this->createPaginatorWithExtraParametersWithoutOutputWalkers([[10]])->count(); @@ -103,7 +104,8 @@ public function testPaginatorNotCaringAboutExtraParametersWithoutOutputWalkers() public function testgetIteratorDoesCareAboutExtraParametersWithoutOutputWalkersWhenResultIsNotEmpty(): void { - $this->connection->expects(self::exactly(1))->method('executeQuery'); + $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock(); + $this->connection->expects(self::exactly(1))->method('executeQuery')->willReturn($result); $this->expectException(QueryException::class); $this->expectExceptionMessage('Too many parameters: the query defines 1 parameters and you bound 2'); diff --git a/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php index b017ba8de6e..4889b983528 100644 --- a/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php @@ -21,9 +21,10 @@ public function testDqlQueryTransformation(string $dql, string $expectedSql): vo $query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [WhereInWalker::class]); $query->setHint(WhereInWalker::HINT_PAGINATOR_HAS_IDS, true); - $result = (new Parser($query))->parse(); + $result = (new Parser($query))->parse(); + $executor = $result->prepareSqlExecutor($query); - self::assertEquals($expectedSql, $result->getSqlExecutor()->getSqlStatements()); + self::assertEquals($expectedSql, $executor->getSqlStatements()); self::assertEquals([0], $result->getSqlParameterPositions(WhereInWalker::PAGINATOR_ID_ALIAS)); } diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index 8de0eb03e25..c6bd50c8f1d 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -34,6 +34,7 @@ use Doctrine\Tests\Models\Forum\ForumAvatar; use Doctrine\Tests\Models\Forum\ForumUser; use Doctrine\Tests\OrmTestCase; +use Exception as BaseException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; @@ -652,6 +653,43 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void $this->_unitOfWork->persist($phone2); } + public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void + { + $connection = new class ([], $this->createMock(Driver::class)) extends Connection { + public function commit(): void + { + throw new BaseException('Commit failed'); + } + + public function rollBack(): void + { + throw new BaseException('Rollback exception'); + } + }; + $this->_emMock = new EntityManagerMock($connection); + $this->_unitOfWork = new UnitOfWorkMock($this->_emMock); + $this->_emMock->setUnitOfWork($this->_unitOfWork); + + // Setup fake persister and id generator + $userPersister = new EntityPersisterMock($this->_emMock, $this->_emMock->getClassMetadata(ForumUser::class)); + $userPersister->setMockIdGeneratorType(ClassMetadata::GENERATOR_TYPE_IDENTITY); + $this->_unitOfWork->setEntityPersister(ForumUser::class, $userPersister); + + // Create a test user + $user = new ForumUser(); + $user->username = 'Jasper'; + $this->_unitOfWork->persist($user); + + try { + $this->_unitOfWork->commit(); + self::fail('Exception expected'); + } catch (BaseException $e) { + self::assertSame('Rollback exception', $e->getMessage()); + self::assertNotNull($e->getPrevious()); + self::assertSame('Commit failed', $e->getPrevious()->getMessage()); + } + } + public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): void { $this->expectException(HydrationException::class); @@ -666,60 +704,52 @@ public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): voi #[Entity] class VersionedAssignedIdentifierEntity { - /** @var int */ #[Id] #[Column(type: 'integer')] - public $id; + public int $id; - /** @var int */ #[Version] #[Column(type: 'integer')] - public $version; + public int $version; } #[Entity] class EntityWithStringIdentifier { - /** @var string|null */ #[Id] #[Column(type: 'string', length: 255)] - public $id; + public string|null $id = null; } #[Entity] class EntityWithBooleanIdentifier { - /** @var bool|null */ #[Id] #[Column(type: 'boolean')] - public $id; + public bool|null $id = null; } #[Entity] class EntityWithCompositeStringIdentifier { - /** @var string|null */ #[Id] #[Column(type: 'string', length: 255)] - public $id1; + public string|null $id1 = null; - /** @var string|null */ #[Id] #[Column(type: 'string', length: 255)] - public $id2; + public string|null $id2 = null; } #[Entity] class EntityWithRandomlyGeneratedField { - /** @var string */ #[Id] #[Column(type: 'string', length: 255)] - public $id; + public string $id; - /** @var int */ #[Column(type: 'integer')] - public $generatedField; + public int $generatedField; public function __construct() { @@ -750,9 +780,8 @@ class EntityWithCascadingAssociation #[GeneratedValue(strategy: 'NONE')] private string $id; - /** @var CascadePersistedEntity|null */ #[ManyToOne(targetEntity: CascadePersistedEntity::class, cascade: ['persist'])] - public $cascaded; + public CascadePersistedEntity|null $cascaded = null; public function __construct() { @@ -768,9 +797,8 @@ class EntityWithNonCascadingAssociation #[GeneratedValue(strategy: 'NONE')] private string $id; - /** @var CascadePersistedEntity|null */ #[ManyToOne(targetEntity: CascadePersistedEntity::class)] - public $nonCascaded; + public CascadePersistedEntity|null $nonCascaded = null; public function __construct() {