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()
{