From 16a8f10fd2fca952b08a656e909dd0fa0a86baed Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 9 Oct 2024 15:37:04 +0200 Subject: [PATCH 01/10] Remove a misleading comment (#11644) --- src/Query/Exec/SingleTableDeleteUpdateExecutor.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Query/Exec/SingleTableDeleteUpdateExecutor.php b/src/Query/Exec/SingleTableDeleteUpdateExecutor.php index 7f7496e397a..4a4a2986467 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 { From 51be1b1d526342543f783f18352862cbdd2a8551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Wed, 9 Oct 2024 15:11:06 +0200 Subject: [PATCH 02/10] Run risky code in finally block catch blocks are not supposed to fail. If you want to do something despite an exception happening, you should do it in a finally block. Closes #7545 --- src/EntityManager.php | 29 ++++++++++++-------- src/UnitOfWork.php | 19 ++++++++------ tests/Tests/ORM/EntityManagerTest.php | 34 ++++++++++++++++++++++++ tests/Tests/ORM/UnitOfWorkTest.php | 38 +++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 19 deletions(-) diff --git a/src/EntityManager.php b/src/EntityManager.php index f7d47d7b12e..da09ed1b77a 100644 --- a/src/EntityManager.php +++ b/src/EntityManager.php @@ -32,7 +32,6 @@ use Doctrine\Persistence\Mapping\MappingException; use Doctrine\Persistence\ObjectRepository; use InvalidArgumentException; -use Throwable; use function array_keys; use function class_exists; @@ -246,18 +245,22 @@ public function transactional($func) $this->conn->beginTransaction(); + $successful = false; + try { $return = $func($this); $this->flush(); $this->conn->commit(); - return $return ?: true; - } catch (Throwable $e) { - $this->close(); - $this->conn->rollBack(); + $successful = true; - throw $e; + return $return ?: true; + } finally { + if (! $successful) { + $this->close(); + $this->conn->rollBack(); + } } } @@ -268,18 +271,22 @@ public function wrapInTransaction(callable $func) { $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(); + $this->conn->rollBack(); + } } } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 39ba6b68b7f..97d80862e39 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -49,7 +49,6 @@ use Exception; use InvalidArgumentException; use RuntimeException; -use Throwable; use UnexpectedValueException; use function array_chunk; @@ -427,6 +426,8 @@ public function commit($entity = null) $conn = $this->em->getConnection(); $conn->beginTransaction(); + $successful = false; + try { // Collection deletions (deletions of complete collections) foreach ($this->collectionDeletions as $collectionToDelete) { @@ -478,16 +479,18 @@ public function commit($entity = null) throw new OptimisticLockException('Commit failed', $object); } - } 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/ORM/EntityManagerTest.php b/tests/Tests/ORM/EntityManagerTest.php index c3ad9f559e7..c9b85f6b4f1 100644 --- a/tests/Tests/ORM/EntityManagerTest.php +++ b/tests/Tests/ORM/EntityManagerTest.php @@ -21,9 +21,12 @@ use Doctrine\ORM\UnitOfWork; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\Mapping\MappingException; +use Doctrine\Tests\Mocks\ConnectionMock; +use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\Models\GeoNames\Country; use Doctrine\Tests\OrmTestCase; +use Exception; use Generator; use InvalidArgumentException; use stdClass; @@ -31,6 +34,7 @@ use function get_class; use function random_int; +use function sprintf; use function sys_get_temp_dir; use function uniqid; @@ -382,4 +386,34 @@ public function testDeprecatedFlushWithArguments(): void $this->entityManager->flush($entity); } + + /** @dataProvider entityManagerMethodNames */ + public function testItPreservesTheOriginalExceptionOnRollbackFailure(string $methodName): void + { + $entityManager = new EntityManagerMock(new class extends ConnectionMock { + public function rollBack(): bool + { + throw new Exception('Rollback exception'); + } + }); + + try { + $entityManager->transactional(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()); + } + } + + /** @return Generator */ + public function entityManagerMethodNames(): Generator + { + foreach (['transactional', 'wrapInTransaction'] as $methodName) { + yield sprintf('%s()', $methodName) => [$methodName]; + } + } } diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index ee475e729d0..ae6b1d282f3 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -41,6 +41,7 @@ use Doctrine\Tests\Models\GeoNames\Country; use Doctrine\Tests\OrmTestCase; use Doctrine\Tests\PHPUnitCompatibility\MockBuilderCompatibilityTools; +use Exception; use PHPUnit\Framework\MockObject\MockObject; use stdClass; @@ -971,6 +972,43 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void $this->_unitOfWork->persist($phone2); } + + public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void + { + $this->_connectionMock = new class extends ConnectionMock { + public function commit(): bool + { + return false; // this should cause an exception + } + + public function rollBack(): bool + { + throw new Exception('Rollback exception'); + } + }; + $this->_emMock = new EntityManagerMock($this->_connectionMock); + $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 (Exception $e) { + self::assertSame('Rollback exception', $e->getMessage()); + self::assertNotNull($e->getPrevious()); + self::assertSame('Commit failed', $e->getPrevious()->getMessage()); + } + } } /** @Entity */ From b6137c89110952664cdc0b2e4df7f98716ff3f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Thu, 10 Oct 2024 10:40:01 +0200 Subject: [PATCH 03/10] Add guard clause It maybe happen that the SQL COMMIT statement is successful, but then something goes wrong. In that kind of case, you do not want to attempt a rollback. This was implemented in UnitOfWork::commit(), but for some reason not in the similar EntityManager methods. --- src/EntityManager.php | 8 ++++++-- tests/Tests/ORM/EntityManagerTest.php | 28 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/EntityManager.php b/src/EntityManager.php index da09ed1b77a..b677c79dc13 100644 --- a/src/EntityManager.php +++ b/src/EntityManager.php @@ -259,7 +259,9 @@ public function transactional($func) } finally { if (! $successful) { $this->close(); - $this->conn->rollBack(); + if ($this->conn->isTransactionActive()) { + $this->conn->rollBack(); + } } } } @@ -285,7 +287,9 @@ public function wrapInTransaction(callable $func) } finally { if (! $successful) { $this->close(); - $this->conn->rollBack(); + if ($this->conn->isTransactionActive()) { + $this->conn->rollBack(); + } } } } diff --git a/tests/Tests/ORM/EntityManagerTest.php b/tests/Tests/ORM/EntityManagerTest.php index c9b85f6b4f1..88ff5ed924f 100644 --- a/tests/Tests/ORM/EntityManagerTest.php +++ b/tests/Tests/ORM/EntityManagerTest.php @@ -29,6 +29,7 @@ use Exception; use Generator; use InvalidArgumentException; +use PHPUnit\Framework\Assert; use stdClass; use TypeError; @@ -409,6 +410,33 @@ public function rollBack(): bool } } + /** @dataProvider entityManagerMethodNames */ + public function testItDoesNotAttemptToRollbackIfNoTransactionIsActive(string $methodName): void + { + $entityManager = new EntityManagerMock( + new class extends ConnectionMock { + public function commit(): bool + { + throw new Exception('Commit exception that happens after doing the actual commit'); + } + + public function rollBack(): bool + { + Assert::fail('Should not attempt to rollback if no transaction is active'); + } + + public function isTransactionActive(): bool + { + return false; + } + } + ); + + $this->expectExceptionMessage('Commit exception'); + $entityManager->$methodName(static function (): void { + }); + } + /** @return Generator */ public function entityManagerMethodNames(): Generator { From bac1c17eabef3aad449197a442380f024f01be7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Thu, 10 Oct 2024 11:05:30 +0200 Subject: [PATCH 04/10] Remove submodule remnant This should make a warning we have in the CI go away. > fatal: No url found for submodule path 'docs/en/_theme' in .gitmodules --- docs/en/_theme | 1 - 1 file changed, 1 deletion(-) delete mode 160000 docs/en/_theme 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 From bea454eefc4bd341e1550b0befd6052db2d175fe Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Thu, 10 Oct 2024 13:54:34 +0200 Subject: [PATCH 05/10] [GH-8471] undeprecate partials completly (#11647) * [GH-8471] Undeprecate all PARTIAL object usage. --- UPGRADE.md | 12 +++++++++--- src/Query/Parser.php | 8 -------- src/UnitOfWork.php | 10 ---------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index a42be6a4378..a44bbd5a35b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,10 +8,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 when used with a hydrator -that is not creating entities, such as the ArrayHydrator. +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. + +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/src/Query/Parser.php b/src/Query/Parser.php index 2a95297efd9..388cfcc8e47 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1850,14 +1850,6 @@ public function JoinAssociationDeclaration() */ public function PartialObjectExpression() { - if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) { - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/issues/8471', - 'PARTIAL syntax in DQL is deprecated for object hydration.' - ); - } - $this->match(TokenType::T_PARTIAL); $partialFieldSet = []; diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 46c2893dcc3..2969a3e0e0f 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -41,7 +41,6 @@ use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister; use Doctrine\ORM\Persisters\Entity\SingleTablePersister; use Doctrine\ORM\Proxy\InternalProxy; -use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Utility\IdentifierFlattener; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Doctrine\Persistence\NotifyPropertyChanged; @@ -2920,15 +2919,6 @@ private function newInstance(ClassMetadata $class) */ public function createEntity($className, array $data, &$hints = []) { - if (isset($hints[SqlWalker::HINT_PARTIAL])) { - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/issues/8471', - 'Partial Objects are deprecated for object hydration (here entity %s)', - $className - ); - } - $class = $this->em->getClassMetadata($className); $id = $this->identifierFlattener->flattenIdentifier($class, $data); From c223b8f635c6a65c4349a8651e7683ce0e93bccb Mon Sep 17 00:00:00 2001 From: eltharin Date: Thu, 10 Oct 2024 14:33:12 +0200 Subject: [PATCH 06/10] Allow named Arguments to be passed to Dto Allow to change argument order or use variadic argument in dto constructor using new named keyword --- .../reference/dql-doctrine-query-language.rst | 65 +++- src/Exception/DuplicateFieldException.php | 17 + src/Exception/NoMatchingPropertyException.php | 17 + src/Internal/Hydration/AbstractHydrator.php | 26 +- src/Query/Parser.php | 74 +++- src/Query/ResultSetMapping.php | 22 -- src/Query/SqlWalker.php | 5 +- src/Query/TokenType.php | 1 + .../Models/CMS/CmsAddressDTONamedArgs.php | 16 + .../Tests/Models/CMS/CmsUserDTONamedArgs.php | 18 + .../Models/CMS/CmsUserDTOVariadicArg.php | 21 ++ .../Tests/ORM/Functional/NewOperatorTest.php | 327 ++++++++++++++++++ .../ORM/Hydration/ResultSetMappingTest.php | 17 - 13 files changed, 550 insertions(+), 76 deletions(-) create mode 100644 src/Exception/DuplicateFieldException.php create mode 100644 src/Exception/NoMatchingPropertyException.php create mode 100644 tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php create mode 100644 tests/Tests/Models/CMS/CmsUserDTONamedArgs.php create mode 100644 tests/Tests/Models/CMS/CmsUserDTOVariadicArg.php diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index ab3cb138889..8608049d1fc 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -591,7 +591,7 @@ You can also nest several DTO : // Bind values to the object properties. } } - + class AddressDTO { public function __construct(string $street, string $city, string $zip) @@ -599,15 +599,72 @@ You can also nest several DTO : // Bind values to the object properties. } } - + .. code-block:: php createQuery('SELECT NEW CustomerDTO(c.name, e.email, NEW AddressDTO(a.street, a.city, a.zip)) FROM Customer c JOIN c.email e JOIN c.address a'); $users = $query->getResult(); // array of CustomerDTO - + Note that you can only pass scalar expressions or other Data Transfer Objects to the constructor. +If you use your data transfer objects for multiple queries, and you would rather not have to +specify arguments that precede the ones you are really interested in, you can use named arguments. + +Consider the following DTO, which uses optional arguments: + +.. code-block:: php + + createQuery('SELECT NEW NAMED CustomerDTO(a.city, c.name) FROM Customer c JOIN c.address a'); + $users = $query->getResult(); // array of CustomerDTO + + // CustomerDTO => {name : 'SMITH', email: null, city: 'London', value: null} + +ORM will also give precedence to column aliases over column names : + +.. code-block:: php + + createQuery('SELECT NEW NAMED CustomerDTO(c.name, CONCAT(a.city, ' ' , a.zip) AS value) FROM Customer c JOIN c.address a'); + $users = $query->getResult(); // array of CustomerDTO + + // CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'} + +To define a custom name for a DTO constructor argument, you can either alias the column with the ``AS`` keyword. + +The ``NAMED`` keyword must precede all DTO you want to instantiate : + +.. code-block:: php + + createQuery('SELECT NEW NAMED CustomerDTO(c.name, NEW NAMED AddressDTO(a.street, a.city, a.zip) AS address) FROM Customer c JOIN c.address a'); + $users = $query->getResult(); // array of CustomerDTO + + // CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'} + +If two arguments have the same name, a ``DuplicateFieldException`` is thrown. +If a field cannot be matched with a property name, a ``NoMatchingPropertyException`` is thrown. This typically happens when using functions without aliasing them. + Using INDEX BY ~~~~~~~~~~~~~~ @@ -1627,7 +1684,7 @@ Select Expressions PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" - NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression + NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable] Conditional Expressions ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Exception/DuplicateFieldException.php b/src/Exception/DuplicateFieldException.php new file mode 100644 index 00000000000..ec7cb00593e --- /dev/null +++ b/src/Exception/DuplicateFieldException.php @@ -0,0 +1,17 @@ + []]; + $rowData = ['data' => [], 'newObjects' => []]; foreach ($data as $key => $value) { $cacheKeyInfo = $this->hydrateColumnInfo($key); @@ -282,10 +282,6 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon $value = $this->buildEnum($value, $cacheKeyInfo['enumType']); } - if (! isset($rowData['newObjects'])) { - $rowData['newObjects'] = []; - } - $rowData['newObjects'][$objIndex]['class'] = $cacheKeyInfo['class']; $rowData['newObjects'][$objIndex]['args'][$argIndex] = $value; break; @@ -341,28 +337,22 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon } foreach ($this->resultSetMapping()->nestedNewObjectArguments as $objIndex => ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex]) { - if (! isset($rowData['newObjects'][$objIndex])) { + if (! isset($rowData['newObjects'][$ownerIndex . ':' . $argIndex])) { continue; } - $newObject = $rowData['newObjects'][$objIndex]; - unset($rowData['newObjects'][$objIndex]); + $newObject = $rowData['newObjects'][$ownerIndex . ':' . $argIndex]; + unset($rowData['newObjects'][$ownerIndex . ':' . $argIndex]); - $class = $newObject['class']; - $args = $newObject['args']; - $obj = $class->newInstanceArgs($args); + $obj = $newObject['class']->newInstanceArgs($newObject['args']); $rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $obj; } - if (isset($rowData['newObjects'])) { - foreach ($rowData['newObjects'] as $objIndex => $newObject) { - $class = $newObject['class']; - $args = $newObject['args']; - $obj = $class->newInstanceArgs($args); + foreach ($rowData['newObjects'] as $objIndex => $newObject) { + $obj = $newObject['class']->newInstanceArgs($newObject['args']); - $rowData['newObjects'][$objIndex]['obj'] = $obj; - } + $rowData['newObjects'][$objIndex]['obj'] = $obj; } return $rowData; diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 875783c87d4..febbca2f5e8 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -6,6 +6,8 @@ use Doctrine\Common\Lexer\Token; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Exception\DuplicateFieldException; +use Doctrine\ORM\Exception\NoMatchingPropertyException; use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; @@ -15,6 +17,7 @@ use ReflectionClass; use function array_intersect; +use function array_key_exists; use function array_search; use function assert; use function class_exists; @@ -30,6 +33,7 @@ use function strrpos; use function strtolower; use function substr; +use function trim; /** * An LL(*) recursive-descent parser for the context-free grammar of the Doctrine Query Language. @@ -1734,20 +1738,26 @@ public function PartialObjectExpression(): AST\PartialObjectExpression */ public function NewObjectExpression(): AST\NewObjectExpression { - $args = []; + $useNamedArguments = false; + $args = []; + $argFieldAlias = []; $this->match(TokenType::T_NEW); + if ($this->lexer->isNextToken(TokenType::T_NAMED)) { + $this->match(TokenType::T_NAMED); + $useNamedArguments = true; + } + $className = $this->AbstractSchemaName(); // note that this is not yet validated $token = $this->lexer->token; $this->match(TokenType::T_OPEN_PARENTHESIS); - $args[] = $this->NewObjectArg(); + $this->addArgument($args, $useNamedArguments); while ($this->lexer->isNextToken(TokenType::T_COMMA)) { $this->match(TokenType::T_COMMA); - - $args[] = $this->NewObjectArg(); + $this->addArgument($args, $useNamedArguments); } $this->match(TokenType::T_CLOSE_PARENTHESIS); @@ -1764,29 +1774,71 @@ public function NewObjectExpression(): AST\NewObjectExpression return $expression; } + /** @param array $args */ + public function addArgument(array &$args, bool $useNamedArguments): void + { + $fieldAlias = null; + + if ($useNamedArguments) { + $startToken = $this->lexer->lookahead?->position ?? 0; + + $newArg = $this->NewObjectArg($fieldAlias); + + $key = $fieldAlias ?? $newArg->field ?? null; + + if ($key === null) { + throw NoMatchingPropertyException::create(trim(substr( + ($this->query->getDQL() ?? ''), + $startToken, + ($this->lexer->lookahead->position ?? 0) - $startToken, + ))); + } + + if (array_key_exists($key, $args)) { + throw DuplicateFieldException::create($key, trim(substr( + ($this->query->getDQL() ?? ''), + $startToken, + ($this->lexer->lookahead->position ?? 0) - $startToken, + ))); + } + + $args[$key] = $newArg; + } else { + $args[] = $this->NewObjectArg($fieldAlias); + } + } + /** - * NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression + * NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable] */ - public function NewObjectArg(): mixed + public function NewObjectArg(string|null &$fieldAlias = null): mixed { + $fieldAlias = null; + assert($this->lexer->lookahead !== null); $token = $this->lexer->lookahead; $peek = $this->lexer->glimpse(); assert($peek !== null); + + $expression = null; + if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) { $this->match(TokenType::T_OPEN_PARENTHESIS); $expression = $this->Subselect(); $this->match(TokenType::T_CLOSE_PARENTHESIS); - - return $expression; + } elseif ($token->type === TokenType::T_NEW) { + $expression = $this->NewObjectExpression(); + } else { + $expression = $this->ScalarExpression(); } - if ($token->type === TokenType::T_NEW) { - return $this->NewObjectExpression(); + if ($this->lexer->isNextToken(TokenType::T_AS)) { + $this->match(TokenType::T_AS); + $fieldAlias = $this->AliasIdentificationVariable(); } - return $this->ScalarExpression(); + return $expression; } /** diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php index c95b089a73b..38ed1bf6416 100644 --- a/src/Query/ResultSetMapping.php +++ b/src/Query/ResultSetMapping.php @@ -4,7 +4,6 @@ namespace Doctrine\ORM\Query; -use function array_merge; use function count; /** @@ -552,25 +551,4 @@ public function addMetaResult( return $this; } - - public function addNewObjectAsArgument(string|int $alias, string|int $objOwner, int $objOwnerIdx): static - { - $owner = [ - 'ownerIndex' => $objOwner, - 'argIndex' => $objOwnerIdx, - ]; - - if (! isset($this->nestedNewObjectArguments[$owner['ownerIndex']])) { - $this->nestedNewObjectArguments[$alias] = $owner; - - return $this; - } - - $this->nestedNewObjectArguments = array_merge( - [$alias => $owner], - $this->nestedNewObjectArguments, - ); - - return $this; - } } diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 46296e719e7..04d7d953ab4 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -1510,6 +1510,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri $this->newObjectStack[] = [$objIndex, $argIndex]; $sqlSelectExpressions[] = $e->dispatch($this); array_pop($this->newObjectStack); + $this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex]; break; case $e instanceof AST\Subselect: @@ -1563,10 +1564,6 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri 'objIndex' => $objIndex, 'argIndex' => $argIndex, ]; - - if ($objOwner !== null && $objOwnerIdx !== null) { - $this->rsm->addNewObjectAsArgument($objIndex, $objOwner, $objOwnerIdx); - } } return implode(', ', $sqlSelectExpressions); diff --git a/src/Query/TokenType.php b/src/Query/TokenType.php index bf1c351c2a6..47cc7912711 100644 --- a/src/Query/TokenType.php +++ b/src/Query/TokenType.php @@ -89,4 +89,5 @@ enum TokenType: int case T_WHEN = 254; case T_WHERE = 255; case T_WITH = 256; + case T_NAMED = 257; } diff --git a/tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php b/tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php new file mode 100644 index 00000000000..547c7fb0c31 --- /dev/null +++ b/tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php @@ -0,0 +1,16 @@ +name = $args['name'] ?? null; + $this->email = $args['email'] ?? null; + $this->phonenumbers = $args['phonenumbers'] ?? null; + $this->address = $args['address'] ?? null; + } +} diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php index 4497af517bf..5a742c1b3a9 100644 --- a/tests/Tests/ORM/Functional/NewOperatorTest.php +++ b/tests/Tests/ORM/Functional/NewOperatorTest.php @@ -4,19 +4,25 @@ namespace Doctrine\Tests\ORM\Functional; +use Doctrine\ORM\Exception\DuplicateFieldException; +use Doctrine\ORM\Exception\NoMatchingPropertyException; use Doctrine\ORM\Query; use Doctrine\ORM\Query\QueryException; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsAddressDTO; +use Doctrine\Tests\Models\CMS\CmsAddressDTONamedArgs; use Doctrine\Tests\Models\CMS\CmsEmail; use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\Models\CMS\CmsUserDTO; +use Doctrine\Tests\Models\CMS\CmsUserDTONamedArgs; +use Doctrine\Tests\Models\CMS\CmsUserDTOVariadicArg; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use function count; +use function sprintf; #[Group('DDC-1574')] class NewOperatorTest extends OrmFunctionalTestCase @@ -1080,6 +1086,327 @@ public function testShouldSupportNestedNewOperators(): void self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); } + + public function testNamedArguments(): void + { + $dql = <<<'SQL' + SELECT + new named CmsUserDTONamedArgs( + e.email, + u.name, + CONCAT(a.country, ' ', a.city, ' ', a.zip) AS address + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name + SQL; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame(sprintf( + '%s %s %s', + $this->fixtures[0]->address->country, + $this->fixtures[0]->address->city, + $this->fixtures[0]->address->zip, + ), $result[0]['user']->address); + self::assertSame( + sprintf( + '%s %s %s', + $this->fixtures[1]->address->country, + $this->fixtures[1]->address->city, + $this->fixtures[1]->address->zip, + ), + $result[1]['user']->address, + ); + self::assertSame( + sprintf( + '%s %s %s', + $this->fixtures[2]->address->country, + $this->fixtures[2]->address->city, + $this->fixtures[2]->address->zip, + ), + $result[2]['user']->address, + ); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } + + public function testVariadicArgument(): void + { + $dql = <<<'SQL' + SELECT + new named CmsUserDTOVariadicArg( + CONCAT(a.country, ' ', a.city, ' ', a.zip) AS address, + e.email, + u.name + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name + SQL; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[2]['user']); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame( + sprintf( + '%s %s %s', + $this->fixtures[0]->address->country, + $this->fixtures[0]->address->city, + $this->fixtures[0]->address->zip, + ), + $result[0]['user']->address, + ); + self::assertSame( + sprintf( + '%s %s %s', + $this->fixtures[1]->address->country, + $this->fixtures[1]->address->city, + $this->fixtures[1]->address->zip, + ), + $result[1]['user']->address, + ); + self::assertSame( + sprintf( + '%s %s %s', + $this->fixtures[2]->address->country, + $this->fixtures[2]->address->city, + $this->fixtures[2]->address->zip, + ), + $result[2]['user']->address, + ); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } + + public function testShouldSupportNestedNewOperatorsAndNamedArguments(): void + { + $dql = ' + SELECT + new named CmsUserDTONamedArgs( + e.email, + u.name as name, + new CmsAddressDTO( + a.country, + a.city, + a.zip + ) as addressDto + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']); + + self::assertNull($result[0]['user']->address); + self::assertNull($result[1]['user']->address); + self::assertNull($result[2]['user']->address); + + self::assertInstanceOf(CmsAddressDTO::class, $result[0]['user']->addressDto); + self::assertInstanceOf(CmsAddressDTO::class, $result[1]['user']->addressDto); + self::assertInstanceOf(CmsAddressDTO::class, $result[2]['user']->addressDto); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame($this->fixtures[0]->address->city, $result[0]['user']->addressDto->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]['user']->addressDto->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]['user']->addressDto->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]['user']->addressDto->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]['user']->addressDto->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]['user']->addressDto->country); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } + + public function testShouldSupportNestedNamedArguments(): void + { + $dql = ' + SELECT + new named CmsUserDTONamedArgs( + e.email, + u.name as name, + new named CmsAddressDTONamedArgs( + a.zip, + a.city, + a.country + ) as addressDtoNamedArgs + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']); + + self::assertNull($result[0]['user']->address); + self::assertNull($result[1]['user']->address); + self::assertNull($result[2]['user']->address); + + self::assertNull($result[0]['user']->addressDto); + self::assertNull($result[1]['user']->addressDto); + self::assertNull($result[2]['user']->addressDto); + + self::assertInstanceOf(CmsAddressDTONamedArgs::class, $result[0]['user']->addressDtoNamedArgs); + self::assertInstanceOf(CmsAddressDTONamedArgs::class, $result[1]['user']->addressDtoNamedArgs); + self::assertInstanceOf(CmsAddressDTONamedArgs::class, $result[2]['user']->addressDtoNamedArgs); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame($this->fixtures[0]->address->city, $result[0]['user']->addressDtoNamedArgs->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]['user']->addressDtoNamedArgs->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]['user']->addressDtoNamedArgs->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]['user']->addressDtoNamedArgs->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]['user']->addressDtoNamedArgs->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]['user']->addressDtoNamedArgs->country); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } + + public function testExceptionIfTwoAliases(): void + { + $dql = ' + SELECT + new named Doctrine\Tests\Models\CMS\CmsUserDTO( + u.name, + u.username AS name + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u'; + + $this->expectException(DuplicateFieldException::class); + $this->expectExceptionMessage('Name "name" for "u.username AS name" already in use.'); + + $query = $this->_em->createQuery($dql); + $result = $query->getResult(); + } + + public function testExceptionIfFunctionHasNoAlias(): void + { + $dql = " + SELECT + new named Doctrine\Tests\Models\CMS\CmsUserDTO( + u.name, + CASE WHEN (e.email = 'email@test1.com') THEN 'TEST1' ELSE 'OTHER_TEST' END + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e"; + + $this->expectException(NoMatchingPropertyException::class); + $this->expectExceptionMessage('Column name "CASE WHEN (e.email = \'email@test1.com\') THEN \'TEST1\' ELSE \'OTHER_TEST\' END" does not match any property name. Consider aliasing it to the name of an existing property.'); + + $query = $this->_em->createQuery($dql); + $result = $query->getResult(); + } } class ClassWithTooMuchArgs diff --git a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php index 14b9205abfe..0c20eab0866 100644 --- a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php +++ b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php @@ -102,21 +102,4 @@ public function testIndexByMetadataColumn(): void self::assertTrue($this->_rsm->hasIndexBy('lu')); } - - public function testNewObjectNestedArgumentsDeepestLeavesShouldComeFirst(): void - { - $this->_rsm->addNewObjectAsArgument('objALevel2', 'objALevel1', 0); - $this->_rsm->addNewObjectAsArgument('objALevel3', 'objALevel2', 1); - $this->_rsm->addNewObjectAsArgument('objBLevel3', 'objBLevel2', 0); - $this->_rsm->addNewObjectAsArgument('objBLevel2', 'objBLevel1', 1); - - $expectedArgumentMapping = [ - 'objALevel3' => ['ownerIndex' => 'objALevel2', 'argIndex' => 1], - 'objALevel2' => ['ownerIndex' => 'objALevel1', 'argIndex' => 0], - 'objBLevel3' => ['ownerIndex' => 'objBLevel2', 'argIndex' => 0], - 'objBLevel2' => ['ownerIndex' => 'objBLevel1', 'argIndex' => 1], - ]; - - self::assertSame($expectedArgumentMapping, $this->_rsm->nestedNewObjectArguments); - } } From 39d2136f466187a8d1169c1fc307ebd8244a0e1a Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Thu, 10 Oct 2024 15:15:08 +0200 Subject: [PATCH 07/10] Fix different first/max result values taking up query cache space (#11188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a test covering the #11112 issue * Add new OutputWalker and SqlFinalizer interfaces * Add a SingleSelectSqlFinalizer that can take care of adding offset/limit as well as locking mode statements to a given SQL query. Add a FinalizedSelectExecutor that executes given, finalized SQL statements. * In SqlWalker, split SQL query generation into the two parts that shall happen before and after the finalization phase. Move the part that generates "pre-finalization" SQL into a dedicated method. Use a side channel in SingleSelectSqlFinalizer to access the "finalization" logic and avoid duplication. * Fix CS violations * Skip the GH11112 test while applying refactorings * Avoid a Psalm complaint due to invalid (?) docblock syntax * Establish alternate code path - queries can obtain the sql executor through the finalizer, parser knows about output walkers yielding finalizers * Remove a possibly premature comment * Re-enable the #11112 test * Fix CS * Make RootTypeWalker inherit from SqlOutputWalker so it becomes finalizer-aware * Update QueryCacheTest, since first/max results no longer need extra cache entries * Fix ParserResultSerializationTest by forcing the parser to produce a ParserResult of the old kind (with the executor already constructed) * Fix WhereInWalkerTest * Update lib/Doctrine/ORM/Query/Exec/PreparedExecutorFinalizer.php Co-authored-by: Grégoire Paris * Fix tests * Fix a Psalm complaint * Fix a test * Fix CS * Make the NullSqlWalker an instance of SqlOutputWalker * Avoid multiple cache entries caused by LimitSubqueryOutputWalker * Fix Psalm complaints * Fix static analysis complaints * Remove experimental code that I committed accidentally * Remove unnecessary baseline entry * Make AddUnknownQueryComponentWalker subclass SqlOutputWalker That way, we have no remaining classes in the codebase subclassing SqlWalker but not SqlOutputWalker * Use more expressive exception classes * Add a deprecation message * Move SqlExecutor creation to ParserResult, to minimize public methods available on it * Avoid keeping the SqlExecutor in the Query, since it must be generated just in time (e. g. in case Query parameters change) * Address PHPStan complaints * Fix tests * Small refactorings * Add an upgrade notice * Small refactorings * Update the Psalm baseline * Add a missing namespace import * Update Psalm baseline * Fix CS * Fix Psalm baseline --------- Co-authored-by: Grégoire Paris --- UPGRADE.md | 9 ++ psalm-baseline.xml | 14 +-- src/Query.php | 34 +++++++- src/Query/Exec/FinalizedSelectExecutor.php | 33 +++++++ src/Query/Exec/PreparedExecutorFinalizer.php | 28 ++++++ src/Query/Exec/SingleSelectSqlFinalizer.php | 64 ++++++++++++++ src/Query/Exec/SqlFinalizer.php | 26 ++++++ src/Query/OutputWalker.php | 28 ++++++ src/Query/Parser.php | 22 ++++- src/Query/ParserResult.php | 37 +++++++- src/Query/SqlOutputWalker.php | 29 +++++++ src/Query/SqlWalker.php | 85 ++++++++++--------- src/Tools/Pagination/CountOutputWalker.php | 11 +-- .../Pagination/LimitSubqueryOutputWalker.php | 71 ++++++++++++---- src/Tools/Pagination/RootTypeWalker.php | 16 +++- tests/Tests/Mocks/NullSqlWalker.php | 20 +++-- .../ParserResultSerializationTest.php | 38 +++++++-- tests/Tests/ORM/Functional/QueryCacheTest.php | 8 +- .../ORM/Functional/Ticket/GH11112Test.php | 79 +++++++++++++++++ .../Tests/ORM/Query/CustomTreeWalkersTest.php | 9 +- .../LimitSubqueryOutputWalkerTest.php | 23 +++++ .../ORM/Tools/Pagination/PaginatorTest.php | 6 +- .../Tools/Pagination/WhereInWalkerTest.php | 5 +- 23 files changed, 591 insertions(+), 104 deletions(-) create mode 100644 src/Query/Exec/FinalizedSelectExecutor.php create mode 100644 src/Query/Exec/PreparedExecutorFinalizer.php create mode 100644 src/Query/Exec/SingleSelectSqlFinalizer.php create mode 100644 src/Query/Exec/SqlFinalizer.php create mode 100644 src/Query/OutputWalker.php create mode 100644 src/Query/SqlOutputWalker.php create mode 100644 tests/Tests/ORM/Functional/Ticket/GH11112Test.php diff --git a/UPGRADE.md b/UPGRADE.md index a44bbd5a35b..9de8af04d38 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,14 @@ # 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, diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2d96702abe2..28ed8aab049 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1868,6 +1868,12 @@ _sqlStatements = &$this->sqlStatements]]> + + + + + + @@ -1996,6 +2002,9 @@ + + + @@ -2057,11 +2066,6 @@ - - - - - parameters)]]> diff --git a/src/Query.php b/src/Query.php index 2654e04b0cc..48464e52631 100644 --- a/src/Query.php +++ b/src/Query.php @@ -18,6 +18,8 @@ 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; @@ -33,6 +35,7 @@ use function count; use function get_debug_type; use function in_array; +use function is_a; use function is_int; use function ksort; use function md5; @@ -196,7 +199,7 @@ class Query extends AbstractQuery */ public function getSQL() { - return $this->parse()->getSqlExecutor()->getSqlStatements(); + return $this->getSqlExecutor()->getSqlStatements(); } /** @@ -285,7 +288,7 @@ private function parse(): ParserResult */ protected function _doExecute() { - $executor = $this->parse()->getSqlExecutor(); + $executor = $this->getSqlExecutor(); if ($this->_queryCacheProfile) { $executor->setQueryCacheProfile($this->_queryCacheProfile); @@ -813,11 +816,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' ); } @@ -836,4 +859,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..a97e6f428c5 --- /dev/null +++ b/src/Query/Exec/FinalizedSelectExecutor.php @@ -0,0 +1,33 @@ +sqlStatements = $sql; + } + + /** + * @param list|array $params + * @param array|array $types + */ + 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..b89b124e352 --- /dev/null +++ b/src/Query/Exec/PreparedExecutorFinalizer.php @@ -0,0 +1,28 @@ +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..5180be9e7b2 --- /dev/null +++ b/src/Query/Exec/SingleSelectSqlFinalizer.php @@ -0,0 +1,64 @@ +sql = $sql; + } + + /** + * This method exists temporarily to support old SqlWalker interfaces. + * + * @internal + * + * @psalm-internal Doctrine\ORM + */ + public function finalizeSql(Query $query): string + { + $platform = $query->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/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 e5d36c9bbe3..86a4e4d3b64 100644 --- a/src/Query/ParserResult.php +++ b/src/Query/ParserResult.php @@ -4,7 +4,10 @@ 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; @@ -20,15 +23,23 @@ class ParserResult 'sqlExecutor' => '_sqlExecutor', 'resultSetMapping' => '_resultSetMapping', 'parameterMappings' => '_parameterMappings', + 'sqlFinalizer' => 'sqlFinalizer', ]; /** * The SQL executor used for executing the SQL. * - * @var AbstractSqlExecutor + * @var ?AbstractSqlExecutor */ private $sqlExecutor; + /** + * The SQL executor used for executing the SQL. + * + * @var ?SqlFinalizer + */ + private $sqlFinalizer; + /** * The ResultSetMapping that describes how to map the SQL result set. * @@ -75,6 +86,8 @@ public function setResultSetMapping(ResultSetMapping $rsm) /** * Sets the SQL executor that should be used for this ParserResult. * + * @deprecated + * * @param AbstractSqlExecutor $executor * * @return void @@ -87,13 +100,33 @@ public function setSqlExecutor($executor) /** * Gets the SQL executor used by this ParserResult. * - * @return AbstractSqlExecutor + * @deprecated + * + * @return ?AbstractSqlExecutor */ public function getSqlExecutor() { 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..e737e1c9c53 --- /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 4c25fb63a68..e4e2de5c5c1 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -16,7 +16,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; @@ -49,8 +48,6 @@ */ class SqlWalker implements TreeWalker { - use LockSqlHelper; - public const HINT_DISTINCT = 'doctrine.distinct'; /** @@ -278,34 +275,48 @@ public function setQueryComponent($dqlAlias, array $queryComponent) /** * 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. + * * @param AST\DeleteStatement|AST\UpdateStatement|AST\SelectStatement $AST * * @return Exec\AbstractSqlExecutor - * - * @not-deprecated */ public function getExecutor($AST) { switch (true) { case $AST instanceof AST\DeleteStatement: - $primaryClass = $this->em->getClassMetadata($AST->deleteClause->abstractSchemaName); - - return $primaryClass->isInheritanceTypeJoined() - ? new Exec\MultiTableDeleteExecutor($AST, $this) - : new Exec\SingleTableDeleteUpdateExecutor($AST, $this); + return $this->createDeleteStatementExecutor($AST); case $AST instanceof AST\UpdateStatement: - $primaryClass = $this->em->getClassMetadata($AST->updateClause->abstractSchemaName); - - return $primaryClass->isInheritanceTypeJoined() - ? new Exec\MultiTableUpdateExecutor($AST, $this) - : new Exec\SingleTableDeleteUpdateExecutor($AST, $this); + return $this->createUpdateStatementExecutor($AST); default: return new Exec\SingleSelectExecutor($AST, $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. * @@ -561,10 +572,15 @@ private function generateFilterConditionSQL( */ public function walkSelectStatement(AST\SelectStatement $AST) { - $limit = $this->query->getMaxResults(); - $offset = $this->query->getFirstResult(); - $lockMode = $this->query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE; - $sql = $this->walkSelectClause($AST->selectClause) + $sql = $this->createSqlForFinalizer($AST); + $finalizer = new Exec\SingleSelectSqlFinalizer($sql); + + return $finalizer->finalizeSql($this->query); + } + + protected function createSqlForFinalizer(AST\SelectStatement $AST): string + { + $sql = $this->walkSelectClause($AST->selectClause) . $this->walkFromClause($AST->fromClause) . $this->walkWhereClause($AST->whereClause); @@ -585,31 +601,22 @@ public function walkSelectStatement(AST\SelectStatement $AST) $sql .= ' ORDER BY ' . $orderBySql; } - $sql = $this->platform->modifyLimitQuery($sql, $limit, $offset); - - if ($lockMode === LockMode::NONE) { - return $sql; - } + $this->assertOptimisticLockingHasAllClassesVersioned(); - 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 0929e2ab065..92615f8aab5 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; @@ -36,7 +36,7 @@ * * @psalm-import-type QueryComponent from Parser */ -class CountOutputWalker extends SqlWalker +class CountOutputWalker extends SqlOutputWalker { /** @var AbstractPlatform */ private $platform; @@ -62,16 +62,13 @@ public function __construct($query, $parserResult, array $queryComponents) parent::__construct($query, $parserResult, $queryComponents); } - /** - * {@inheritDoc} - */ - public function walkSelectStatement(SelectStatement $AST) + protected function createSqlForFinalizer(SelectStatement $AST): string { if ($this->platform instanceof SQLServerPlatform) { $AST->orderByClause = null; } - $sql = parent::walkSelectStatement($AST); + $sql = parent::createSqlForFinalizer($AST); if ($AST->groupByClause) { return sprintf( diff --git a/src/Tools/Pagination/LimitSubqueryOutputWalker.php b/src/Tools/Pagination/LimitSubqueryOutputWalker.php index f92c1145d4f..0e858ce0ecb 100644 --- a/src/Tools/Pagination/LimitSubqueryOutputWalker.php +++ b/src/Tools/Pagination/LimitSubqueryOutputWalker.php @@ -14,15 +14,20 @@ use Doctrine\ORM\Mapping\QuoteStrategy; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\AST\DeleteStatement; 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\UpdateStatement; +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 +55,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(); @@ -158,11 +165,33 @@ private function rebuildOrderByForRowNumber(SelectStatement $AST): void */ public function walkSelectStatement(SelectStatement $AST) { + $sqlFinalizer = $this->getFinalizer($AST); + + $query = $this->getQuery(); + + $abstractSqlExecutor = $sqlFinalizer->createExecutor($query); + + return $abstractSqlExecutor->getSqlStatements(); + } + + /** + * @param DeleteStatement|UpdateStatement|SelectStatement $AST + * + * @return SingleSelectSqlFinalizer + */ + public function getFinalizer($AST): SqlFinalizer + { + if (! $AST instanceof SelectStatement) { + throw new LogicException(self::class . ' is to be used on SelectStatements only'); + } + if ($this->platformSupportsRowNumber()) { - return $this->walkSelectStatementWithRowNumber($AST); + $sql = $this->createSqlWithRowNumber($AST); + } else { + $sql = $this->createSqlWithoutRowNumber($AST); } - return $this->walkSelectStatementWithoutRowNumber($AST); + return new SingleSelectSqlFinalizer($sql); } /** @@ -174,6 +203,16 @@ public function walkSelectStatement(SelectStatement $AST) * @throws RuntimeException */ public function walkSelectStatementWithRowNumber(SelectStatement $AST) + { + // 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'; @@ -203,13 +242,6 @@ public function walkSelectStatementWithRowNumber(SelectStatement $AST) $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 @@ -232,6 +264,16 @@ public function walkSelectStatementWithRowNumber(SelectStatement $AST) * @throws RuntimeException */ public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, $addMissingItemsFromOrderByToSelect = true) + { + // 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) { @@ -260,13 +302,6 @@ public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, $addMi // 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 dc1d77a51c7..ec45dbd6fc1 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 $AST): string { @@ -45,4 +48,13 @@ public function walkSelectStatement(AST\SelectStatement $AST): string ->getEntityManager() )[0]; } + + public function getFinalizer($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/tests/Tests/Mocks/NullSqlWalker.php b/tests/Tests/Mocks/NullSqlWalker.php index 9d54e703547..90cf0c6e926 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. */ -class NullSqlWalker extends SqlWalker +class NullSqlWalker extends SqlOutputWalker { public function walkSelectStatement(AST\SelectStatement $AST): string { @@ -29,13 +31,15 @@ public function walkDeleteStatement(AST\DeleteStatement $AST): string return ''; } - public function getExecutor($AST): AbstractSqlExecutor + public function getFinalizer($AST): 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/Functional/ParserResultSerializationTest.php b/tests/Tests/ORM/Functional/ParserResultSerializationTest.php index 0841c5ff3a0..7b2ae59119a 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; @@ -35,18 +37,40 @@ protected function setUp(): void * * @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 */ @@ -75,6 +99,9 @@ public function testItSerializesParserResultWithAForwardCompatibleFormat(): 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); $serialized = serialize($parserResult); $this->assertStringNotContainsString( @@ -118,11 +145,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); @@ -132,7 +160,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 4935c3d78ee..3894ecef697 100644 --- a/tests/Tests/ORM/Functional/QueryCacheTest.php +++ b/tests/Tests/ORM/Functional/QueryCacheTest.php @@ -43,7 +43,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); @@ -55,11 +55,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); @@ -70,7 +70,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..3dbdb533b33 --- /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 b2267078ff5..eb27b6113a3 100644 --- a/tests/Tests/ORM/Query/CustomTreeWalkersTest.php +++ b/tests/Tests/ORM/Query/CustomTreeWalkersTest.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\AST; use Doctrine\ORM\Query\QueryException; use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\TreeWalker; @@ -110,13 +111,13 @@ public function testSupportsSeveralHintsQueries(): void } } -class AddUnknownQueryComponentWalker extends Query\SqlWalker +class AddUnknownQueryComponentWalker extends Query\SqlOutputWalker { - public function walkSelectStatement(Query\AST\SelectStatement $selectStatement): void + protected function createSqlForFinalizer(AST\SelectStatement $AST): string { - parent::walkSelectStatement($selectStatement); - $this->setQueryComponent('x', []); + + return parent::createSqlForFinalizer($AST); } } diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php index cb69a3a5674..95d2047d593 100644 --- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\ORM\Query; use Doctrine\ORM\Tools\Pagination\LimitSubqueryOutputWalker; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use function class_exists; @@ -385,6 +386,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 1c8b47ac0cf..23ff3b90be4 100644 --- a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php +++ b/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php @@ -87,7 +87,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(); @@ -96,7 +97,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(Query\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 1f1710d2cf1..6f5c9c6dd22 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)); } From ee0d7197dd2be959bb07bf25dee442ccf9d7aa8c Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 11 Oct 2024 13:00:52 +0200 Subject: [PATCH 08/10] test: cover all transactional methods in `EntityManagerTest::testItPreservesTheOriginalExceptionOnRollbackFailure()` --- tests/Tests/ORM/EntityManagerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tests/ORM/EntityManagerTest.php b/tests/Tests/ORM/EntityManagerTest.php index 88ff5ed924f..202560602cd 100644 --- a/tests/Tests/ORM/EntityManagerTest.php +++ b/tests/Tests/ORM/EntityManagerTest.php @@ -399,7 +399,7 @@ public function rollBack(): bool }); try { - $entityManager->transactional(static function (): void { + $entityManager->$methodName(static function (): void { throw new Exception('Original exception'); }); self::fail('Exception expected'); From 5bfb7449671a392242477c5ba63331076e936597 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Fri, 11 Oct 2024 15:29:19 +0200 Subject: [PATCH 09/10] The MySQL/Maria EnumType added in DBAL 4.2 has a new known option "values". (#11657) --- src/Tools/SchemaTool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tools/SchemaTool.php b/src/Tools/SchemaTool.php index cff59aecdd8..e0a24d9459d 100644 --- a/src/Tools/SchemaTool.php +++ b/src/Tools/SchemaTool.php @@ -47,7 +47,7 @@ */ class SchemaTool { - private const KNOWN_COLUMN_OPTIONS = ['comment', 'unsigned', 'fixed', 'default']; + private const KNOWN_COLUMN_OPTIONS = ['comment', 'unsigned', 'fixed', 'default', 'values']; private readonly AbstractPlatform $platform; private readonly QuoteStrategy $quoteStrategy; From 522863116a245c7bfcf6f5f2458755601b1d1909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Fri, 11 Oct 2024 20:23:33 +0200 Subject: [PATCH 10/10] Update branch metadata (#11663) 2.20.0 just got released --- .doctrine-project.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.doctrine-project.json b/.doctrine-project.json index f3a38fb4bdd..7865e2dd1a6 100644 --- a/.doctrine-project.json +++ b/.doctrine-project.json @@ -35,17 +35,23 @@ "slug": "3.0", "maintained": false }, + { + "name": "2.21", + "branchName": "2.21.x", + "slug": "2.21", + "upcoming": true + }, { "name": "2.20", "branchName": "2.20.x", "slug": "2.20", - "upcoming": true + "maintained": true }, { "name": "2.19", "branchName": "2.19.x", "slug": "2.19", - "maintained": true + "maintained": false }, { "name": "2.18",