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", diff --git a/UPGRADE.md b/UPGRADE.md index f827f87b239..47eb24b0acf 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -783,6 +783,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, @@ -791,10 +800,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/docs/en/index.rst b/docs/en/index.rst index 4d23062cd0d..32f8c070175 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -74,6 +74,7 @@ Advanced Topics * :doc:`Improving Performance ` * :doc:`Caching ` * :doc:`Partial Hydration ` +* :doc:`Partial Objects ` * :doc:`Change Tracking Policies ` * :doc:`Best Practices ` * :doc:`Metadata Drivers ` diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index ab3cb138889..e668c08fd82 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -533,14 +533,23 @@ back. Instead, you receive only arrays as a flat rectangular result set, similar to how you would if you were just using SQL directly and joining some data. -If you want to select a partial number of fields for hydration entity in -the context of array hydration and joins you can use the ``partial`` DQL keyword: +If you want to select partial objects or fields in array hydration you can use the ``partial`` +DQL keyword: + +.. code-block:: php + + createQuery('SELECT partial u.{id, username} FROM CmsUser u'); + $users = $query->getResult(); // array of partially loaded CmsUser objects + +You can use the partial syntax when joining as well: .. code-block:: php createQuery('SELECT partial u.{id, username}, partial a.{id, name} FROM CmsUser u JOIN u.articles a'); - $users = $query->getArrayResult(); // array of partially loaded CmsUser and CmsArticle fields + $usersArray = $query->getArrayResult(); // array of partially loaded CmsUser and CmsArticle fields + $users = $query->getResult(); // array of partially loaded CmsUser objects "NEW" Operator Syntax ^^^^^^^^^^^^^^^^^^^^^ @@ -591,7 +600,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 +608,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 ~~~~~~~~~~~~~~ @@ -1370,6 +1436,15 @@ exist mostly internal query hints that are not be consumed in userland. However the following few hints are to be used in userland: + +- ``Query::HINT_FORCE_PARTIAL_LOAD`` - Allows to hydrate objects + although not all their columns are fetched. This query hint can be + used to handle memory consumption problems with large result-sets + that contain char or binary data. Doctrine has no way of implicitly + reloading this data. Partially loaded objects have to be passed to + ``EntityManager::refresh()`` if they are to be reloaded fully from + the database. This query hint is deprecated and will be removed + in the future (\ `Details `_) - ``Query::HINT_REFRESH`` - This query is used internally by ``EntityManager::refresh()`` and can be used in userland as well. If you specify this hint and a query returns the data for an entity @@ -1627,7 +1702,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/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst index 16879c45c52..fda14b98efa 100644 --- a/docs/en/reference/partial-hydration.rst +++ b/docs/en/reference/partial-hydration.rst @@ -1,11 +1,6 @@ Partial Hydration ================= -.. note:: - - Creating Partial Objects through DQL was possible in ORM 2, - but is only supported for array hydration as of ORM 3. - Partial hydration of entities is allowed in the array hydrator, when only a subset of the fields of an entity are loaded from the database and the nested results are still created based on the entity relationship structure. diff --git a/docs/en/reference/partial-objects.rst b/docs/en/reference/partial-objects.rst new file mode 100644 index 00000000000..3123c083f3f --- /dev/null +++ b/docs/en/reference/partial-objects.rst @@ -0,0 +1,88 @@ +Partial Objects +=============== + +A partial object is an object whose state is not fully initialized +after being reconstituted from the database and that is +disconnected from the rest of its data. The following section will +describe why partial objects are problematic and what the approach +of Doctrine to this problem is. + +.. note:: + + The partial object problem in general does not apply to + methods or queries where you do not retrieve the query result as + objects. Examples are: ``Query#getArrayResult()``, + ``Query#getScalarResult()``, ``Query#getSingleScalarResult()``, + etc. + +.. warning:: + + Use of partial objects is tricky. Fields that are not retrieved + from the database will not be updated by the UnitOfWork even if they + get changed in your objects. You can only promote a partial object + to a fully-loaded object by calling ``EntityManager#refresh()`` + or a DQL query with the refresh flag. + + +What is the problem? +-------------------- + +In short, partial objects are problematic because they are usually +objects with broken invariants. As such, code that uses these +partial objects tends to be very fragile and either needs to "know" +which fields or methods can be safely accessed or add checks around +every field access or method invocation. The same holds true for +the internals, i.e. the method implementations, of such objects. +You usually simply assume the state you need in the method is +available, after all you properly constructed this object before +you pushed it into the database, right? These blind assumptions can +quickly lead to null reference errors when working with such +partial objects. + +It gets worse with the scenario of an optional association (0..1 to +1). When the associated field is NULL, you don't know whether this +object does not have an associated object or whether it was simply +not loaded when the owning object was loaded from the database. + +These are reasons why many ORMs do not allow partial objects at all +and instead you always have to load an object with all its fields +(associations being proxied). One secure way to allow partial +objects is if the programming language/platform allows the ORM tool +to hook deeply into the object and instrument it in such a way that +individual fields (not only associations) can be loaded lazily on +first access. This is possible in Java, for example, through +bytecode instrumentation. In PHP though this is not possible, so +there is no way to have "secure" partial objects in an ORM with +transparent persistence. + +Doctrine, by default, does not allow partial objects. That means, +any query that only selects partial object data and wants to +retrieve the result as objects (i.e. ``Query#getResult()``) will +raise an exception telling you that partial objects are dangerous. +If you want to force a query to return you partial objects, +possibly as a performance tweak, you can use the ``partial`` +keyword as follows: + +.. code-block:: php + + createQuery("select partial u.{id,name} from MyApp\Domain\User u"); + +You can also get a partial reference instead of a proxy reference by +calling: + +.. code-block:: php + + getPartialReference('MyApp\Domain\User', 1); + +Partial references are objects with only the identifiers set as they +are passed to the second argument of the ``getPartialReference()`` method. +All other fields are null. + +When should I force partial objects? +------------------------------------ + +Mainly for optimization purposes, but be careful of premature +optimization as partial objects lead to potentially more fragile +code. diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 7ccdfb3a054..f52801c6b37 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -38,6 +38,7 @@ reference/native-sql reference/change-tracking-policies reference/partial-hydration + reference/partial-objects reference/attributes-reference reference/xml-mapping reference/php-mapping diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 23496d29fc7..d8decd3e1b3 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -207,14 +207,14 @@ - - - - + + + + @@ -396,6 +396,7 @@ + @@ -885,6 +886,9 @@ + + + @@ -1075,6 +1079,12 @@ + + getSqlStatements()]]> + + + + diff --git a/src/Cache/DefaultQueryCache.php b/src/Cache/DefaultQueryCache.php index f3bb8ac95c8..08e703cd4b0 100644 --- a/src/Cache/DefaultQueryCache.php +++ b/src/Cache/DefaultQueryCache.php @@ -16,6 +16,7 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\UnitOfWork; use function array_map; @@ -210,6 +211,10 @@ public function put(QueryCacheKey $key, ResultSetMapping $rsm, mixed $result, ar throw FeatureNotImplemented::nonSelectStatements(); } + if (($hints[SqlWalker::HINT_PARTIAL] ?? false) === true || ($hints[Query::HINT_FORCE_PARTIAL_LOAD] ?? false) === true) { + throw FeatureNotImplemented::partialEntities(); + } + if (! ($key->cacheMode & Cache::MODE_PUT)) { return false; } diff --git a/src/Cache/Exception/FeatureNotImplemented.php b/src/Cache/Exception/FeatureNotImplemented.php index 8767d574190..7bae90b775d 100644 --- a/src/Cache/Exception/FeatureNotImplemented.php +++ b/src/Cache/Exception/FeatureNotImplemented.php @@ -20,4 +20,9 @@ public static function nonSelectStatements(): self { return new self('Second-level cache query supports only select statements.'); } + + public static function partialEntities(): self + { + return new self('Second level cache does not support partial entities.'); + } } diff --git a/src/EntityManager.php b/src/EntityManager.php index 4c31a3d1257..832e082a39a 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; @@ -173,18 +172,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/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/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php index 53773e5cf91..13e385c7f08 100644 --- a/src/Mapping/ClassMetadata.php +++ b/src/Mapping/ClassMetadata.php @@ -7,6 +7,7 @@ use BackedEnum; use BadMethodCallException; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Types; use Doctrine\Instantiator\Instantiator; use Doctrine\Instantiator\InstantiatorInterface; use Doctrine\ORM\Cache\Exception\NonCacheableEntityAssociation; @@ -22,6 +23,7 @@ use ReflectionProperty; use Stringable; +use function array_column; use function array_diff; use function array_intersect; use function array_key_exists; @@ -33,6 +35,7 @@ use function assert; use function class_exists; use function count; +use function defined; use function enum_exists; use function explode; use function in_array; @@ -1118,9 +1121,7 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array { $field = $this->reflClass->getProperty($mapping['fieldName']); - $mapping = $this->typedFieldMapper->validateAndComplete($mapping, $field); - - return $mapping; + return $this->typedFieldMapper->validateAndComplete($mapping, $field); } /** @@ -1231,6 +1232,14 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping if (! empty($mapping->id)) { $this->containsEnumIdentifier = true; } + + if ( + defined('Doctrine\DBAL\Types\Types::ENUM') + && $mapping->type === Types::ENUM + && ! isset($mapping->options['values']) + ) { + $mapping->options['values'] = array_column($mapping->enumType::cases(), 'value'); + } } return $mapping; diff --git a/src/Mapping/DefaultTypedFieldMapper.php b/src/Mapping/DefaultTypedFieldMapper.php index 49144b8b7c8..40b37b8c426 100644 --- a/src/Mapping/DefaultTypedFieldMapper.php +++ b/src/Mapping/DefaultTypedFieldMapper.php @@ -16,6 +16,7 @@ use function array_merge; use function assert; +use function defined; use function enum_exists; use function is_a; @@ -49,30 +50,40 @@ public function validateAndComplete(array $mapping, ReflectionProperty $field): { $type = $field->getType(); + if (! $type instanceof ReflectionNamedType) { + return $mapping; + } + if ( - ! isset($mapping['type']) - && ($type instanceof ReflectionNamedType) + ! $type->isBuiltin() + && enum_exists($type->getName()) + && (! isset($mapping['type']) || ( + defined('Doctrine\DBAL\Types\Types::ENUM') + && $mapping['type'] === Types::ENUM + )) ) { - if (! $type->isBuiltin() && enum_exists($type->getName())) { - $reflection = new ReflectionEnum($type->getName()); - if (! $reflection->isBacked()) { - throw MappingException::backedEnumTypeRequired( - $field->class, - $mapping['fieldName'], - $type->getName(), - ); - } + $reflection = new ReflectionEnum($type->getName()); + if (! $reflection->isBacked()) { + throw MappingException::backedEnumTypeRequired( + $field->class, + $mapping['fieldName'], + $type->getName(), + ); + } - assert(is_a($type->getName(), BackedEnum::class, true)); - $mapping['enumType'] = $type->getName(); - $type = $reflection->getBackingType(); + assert(is_a($type->getName(), BackedEnum::class, true)); + $mapping['enumType'] = $type->getName(); + $type = $reflection->getBackingType(); - assert($type instanceof ReflectionNamedType); - } + assert($type instanceof ReflectionNamedType); + } - if (isset($this->typedFieldMappings[$type->getName()])) { - $mapping['type'] = $this->typedFieldMappings[$type->getName()]; - } + if (isset($mapping['type'])) { + return $mapping; + } + + if (isset($this->typedFieldMappings[$type->getName()])) { + $mapping['type'] = $this->typedFieldMappings[$type->getName()]; } return $mapping; diff --git a/src/Query.php b/src/Query.php index 65910fddd2c..890b2a9dfa3 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; @@ -70,6 +74,14 @@ class Query extends AbstractQuery */ public const HINT_REFRESH_ENTITY = 'doctrine.refresh.entity'; + /** + * The forcePartialLoad query hint forces a particular query to return + * partial objects. + * + * @todo Rename: HINT_OPTIMIZE + */ + public const HINT_FORCE_PARTIAL_LOAD = 'doctrine.forcePartialLoad'; + /** * The includeMetaColumns query hint causes meta columns like foreign keys and * discriminator columns to be selected and returned as part of the query result. @@ -163,7 +175,7 @@ class Query extends AbstractQuery */ public function getSQL(): string|array { - return $this->parse()->getSqlExecutor()->getSqlStatements(); + return $this->getSqlExecutor()->getSqlStatements(); } /** @@ -242,7 +254,7 @@ private function parse(): ParserResult protected function _doExecute(): Result|int { - $executor = $this->parse()->getSqlExecutor(); + $executor = $this->getSqlExecutor(); if ($this->queryCacheProfile) { $executor->setQueryCacheProfile($this->queryCacheProfile); @@ -653,11 +665,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', ); } @@ -676,4 +708,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; } @@ -1682,10 +1701,6 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration */ public function PartialObjectExpression(): AST\PartialObjectExpression { - if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) { - throw HydrationException::partialObjectHydrationDisallowed(); - } - $this->match(TokenType::T_PARTIAL); $partialFieldSet = []; @@ -1742,20 +1757,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); @@ -1772,29 +1793,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/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/QueryException.php b/src/Query/QueryException.php index ae945b167fe..5c82b20a7a4 100644 --- a/src/Query/QueryException.php +++ b/src/Query/QueryException.php @@ -88,6 +88,15 @@ public static function iterateWithFetchJoinCollectionNotAllowed(AssociationMappi ); } + public static function partialObjectsAreDangerous(): self + { + return new self( + 'Loading partial objects is dangerous. Fetch full objects or consider ' . + 'using a different fetch mode. If you really want partial objects, ' . + 'set the doctrine.forcePartialLoad query hint to TRUE.', + ); + } + /** * @param string[] $assoc * @psalm-param array $assoc diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php index 15b095d8482..920461b53d4 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; /** @@ -549,25 +548,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/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..8164ae13bb8 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. */ @@ -321,6 +335,11 @@ private function generateClassTableInheritanceJoins( $sql .= implode(' AND ', array_filter($sqlParts)); } + // Ignore subclassing inclusion if partial objects is disallowed + if ($this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) { + return $sql; + } + // LEFT JOIN child class tables foreach ($class->subClasses as $subClassName) { $subClass = $this->em->getClassMetadata($subClassName); @@ -479,10 +498,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 +527,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; } /** @@ -659,7 +674,8 @@ public function walkSelectClause(AST\SelectClause $selectClause): string $this->query->setHint(self::HINT_DISTINCT, true); } - $addMetaColumns = $this->query->getHydrationMode() === Query::HYDRATE_OBJECT + $addMetaColumns = ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD) && + $this->query->getHydrationMode() === Query::HYDRATE_OBJECT || $this->query->getHint(Query::HINT_INCLUDE_META_COLUMNS); foreach ($this->selectedClasses as $selectedClass) { @@ -1398,28 +1414,30 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // 1) on Single Table Inheritance: always, since its marginal overhead // 2) on Class Table Inheritance only if partial objects are disallowed, // since it requires outer joining subtables. - foreach ($class->subClasses as $subClassName) { - $subClass = $this->em->getClassMetadata($subClassName); - $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias); + if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) { + foreach ($class->subClasses as $subClassName) { + $subClass = $this->em->getClassMetadata($subClassName); + $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias); - foreach ($subClass->fieldMappings as $fieldName => $mapping) { - if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { - continue; - } + foreach ($subClass->fieldMappings as $fieldName => $mapping) { + if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { + continue; + } - $columnAlias = $this->getSQLColumnAlias($mapping->columnName); - $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform); + $columnAlias = $this->getSQLColumnAlias($mapping->columnName); + $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform); - $col = $sqlTableAlias . '.' . $quotedColumnName; + $col = $sqlTableAlias . '.' . $quotedColumnName; - $type = Type::getType($mapping->type); - $col = $type->convertToPHPValueSQL($col, $this->platform); + $type = Type::getType($mapping->type); + $col = $type->convertToPHPValueSQL($col, $this->platform); - $sqlParts[] = $col . ' AS ' . $columnAlias; + $sqlParts[] = $col . ' AS ' . $columnAlias; - $this->scalarResultAliasMap[$resultAlias][] = $columnAlias; + $this->scalarResultAliasMap[$resultAlias][] = $columnAlias; - $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName); + $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName); + } } } @@ -1510,6 +1528,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 +1582,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/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/Tools/SchemaTool.php b/src/Tools/SchemaTool.php index 970d3426cf2..d68f700466a 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; diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 0f3eca16ecf..6b353be0d64 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -28,7 +28,6 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Exception\UnexpectedAssociationValue; use Doctrine\ORM\Id\AssignedGenerator; -use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Internal\HydrationCompleteHandler; use Doctrine\ORM\Internal\StronglyConnectedComponents; use Doctrine\ORM\Internal\TopologicalSort; @@ -44,14 +43,12 @@ 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\PropertyChangedListener; use Exception; use InvalidArgumentException; use RuntimeException; use Stringable; -use Throwable; use UnexpectedValueException; use function array_chunk; @@ -381,6 +378,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) { @@ -431,16 +430,18 @@ public function commit(): void } catch (DBAL\Exception $e) { throw new OptimisticLockException('Commit failed', null, $e); } - } 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(); @@ -2340,10 +2341,6 @@ public function isCollectionScheduledForDeletion(PersistentCollection $coll): bo */ public function createEntity(string $className, array $data, array &$hints = []): object { - if (isset($hints[SqlWalker::HINT_PARTIAL])) { - throw HydrationException::partialObjectHydrationDisallowed(); - } - $class = $this->em->getClassMetadata($className); $id = $this->identifierFlattener->flattenIdentifier($class, $data); diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php new file mode 100644 index 00000000000..0694e8fff6a --- /dev/null +++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php @@ -0,0 +1,91 @@ + '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'ROMANB', + 'p__phonenumber' => '42', + 'a__id' => '1', + ], + [ + 'u__id' => '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'ROMANB', + 'p__phonenumber' => '43', + 'a__id' => '1', + ], + [ + 'u__id' => '2', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'JWAGE', + 'p__phonenumber' => '91', + 'a__id' => '1', + ], + ]; + + for ($i = 4; $i < 2000; ++$i) { + $resultSet[] = [ + 'u__id' => $i, + 'u__status' => 'developer', + 'u__username' => 'jwage', + 'u__name' => 'Jonathan', + 'sclr0' => 'JWAGE' . $i, + 'p__phonenumber' => '91', + 'a__id' => '1', + ]; + } + + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); + $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); + $this->rsm = new ResultSetMapping(); + + $this->rsm->addEntityResult(CmsUser::class, 'u'); + $this->rsm->addJoinedEntityResult(CmsPhonenumber::class, 'p', 'u', 'phonenumbers'); + $this->rsm->addFieldResult('u', 'u__id', 'id'); + $this->rsm->addFieldResult('u', 'u__status', 'status'); + $this->rsm->addFieldResult('u', 'u__username', 'username'); + $this->rsm->addFieldResult('u', 'u__name', 'name'); + $this->rsm->addScalarResult('sclr0', 'nameUpper'); + $this->rsm->addFieldResult('p', 'p__phonenumber', 'phonenumber'); + $this->rsm->addJoinedEntityResult(CmsAddress::class, 'a', 'u', 'address'); + $this->rsm->addFieldResult('a', 'a__id', 'id'); + } + + public function benchHydration(): void + { + $this->hydrator->hydrateAll($this->result, $this->rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + } +} diff --git a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php new file mode 100644 index 00000000000..14ed606508c --- /dev/null +++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php @@ -0,0 +1,79 @@ + '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'a__id' => '1', + ], + [ + 'u__id' => '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'a__id' => '1', + ], + [ + 'u__id' => '2', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'a__id' => '1', + ], + ]; + + for ($i = 4; $i < 10000; ++$i) { + $resultSet[] = [ + 'u__id' => $i, + 'u__status' => 'developer', + 'u__username' => 'jwage', + 'u__name' => 'Jonathan', + 'a__id' => '1', + ]; + } + + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); + $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); + $this->rsm = new ResultSetMapping(); + + $this->rsm->addEntityResult(CmsUser::class, 'u'); + $this->rsm->addFieldResult('u', 'u__id', 'id'); + $this->rsm->addFieldResult('u', 'u__status', 'status'); + $this->rsm->addFieldResult('u', 'u__username', 'username'); + $this->rsm->addFieldResult('u', 'u__name', 'name'); + $this->rsm->addJoinedEntityResult(CmsAddress::class, 'a', 'u', 'address'); + $this->rsm->addFieldResult('a', 'a__id', 'id'); + } + + public function benchHydration(): void + { + $this->hydrator->hydrateAll($this->result, $this->rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + } +} 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/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/Models/Enums/CardNativeEnum.php b/tests/Tests/Models/Enums/CardNativeEnum.php new file mode 100644 index 00000000000..83d4d59a778 --- /dev/null +++ b/tests/Tests/Models/Enums/CardNativeEnum.php @@ -0,0 +1,25 @@ + ['H', 'D', 'C', 'S', 'Z']])] + public $suit; +} diff --git a/tests/Tests/Models/Enums/TypedCardNativeEnum.php b/tests/Tests/Models/Enums/TypedCardNativeEnum.php new file mode 100644 index 00000000000..59e4eb00e55 --- /dev/null +++ b/tests/Tests/Models/Enums/TypedCardNativeEnum.php @@ -0,0 +1,23 @@ +resetLazyObject(); $this->assertTrue($em->isOpen()); } + + public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void + { + $driver = $this->createMock(Driver::class); + $driver->method('connect') + ->willReturn($this->createMock(Driver\Connection::class)); + + $entityManager = new EntityManagerMock(new class ([], $driver) 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 + { + $driver = $this->createMock(Driver::class); + $driver->method('connect') + ->willReturn($this->createMock(Driver\Connection::class)); + + $entityManager = new EntityManagerMock( + new class ([], $driver) 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/EnumTest.php b/tests/Tests/ORM/Functional/EnumTest.php index 75ccac8b506..ba84a921240 100644 --- a/tests/Tests/ORM/Functional/EnumTest.php +++ b/tests/Tests/ORM/Functional/EnumTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM\Functional; +use Doctrine\DBAL\Types\EnumType; use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Driver\AttributeDriver; @@ -13,6 +14,7 @@ use Doctrine\Tests\Models\DataTransferObjects\DtoWithArrayOfEnums; use Doctrine\Tests\Models\DataTransferObjects\DtoWithEnum; use Doctrine\Tests\Models\Enums\Card; +use Doctrine\Tests\Models\Enums\CardNativeEnum; use Doctrine\Tests\Models\Enums\CardWithDefault; use Doctrine\Tests\Models\Enums\CardWithNullable; use Doctrine\Tests\Models\Enums\Product; @@ -20,10 +22,12 @@ use Doctrine\Tests\Models\Enums\Scale; use Doctrine\Tests\Models\Enums\Suit; use Doctrine\Tests\Models\Enums\TypedCard; +use Doctrine\Tests\Models\Enums\TypedCardNativeEnum; use Doctrine\Tests\Models\Enums\Unit; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\DataProvider; +use function class_exists; use function dirname; use function sprintf; use function uniqid; @@ -55,7 +59,7 @@ public function testEnumMapping(string $cardClass): void $this->_em->flush(); $this->_em->clear(); - $fetchedCard = $this->_em->find(Card::class, $card->id); + $fetchedCard = $this->_em->find($cardClass, $card->id); $this->assertInstanceOf(Suit::class, $fetchedCard->suit); $this->assertEquals(Suit::Clubs, $fetchedCard->suit); @@ -417,6 +421,10 @@ public function testFindByEnum(): void #[DataProvider('provideCardClasses')] public function testEnumWithNonMatchingDatabaseValueThrowsException(string $cardClass): void { + if ($cardClass === TypedCardNativeEnum::class) { + self::markTestSkipped('MySQL won\'t allow us to insert invalid values in this case.'); + } + $this->setUpEntitySchema([$cardClass]); $card = new $cardClass(); @@ -429,7 +437,7 @@ public function testEnumWithNonMatchingDatabaseValueThrowsException(string $card $metadata = $this->_em->getClassMetadata($cardClass); $this->_em->getConnection()->update( $metadata->table['name'], - [$metadata->fieldMappings['suit']->columnName => 'invalid'], + [$metadata->fieldMappings['suit']->columnName => 'Z'], [$metadata->fieldMappings['id']->columnName => $card->id], ); @@ -437,7 +445,7 @@ public function testEnumWithNonMatchingDatabaseValueThrowsException(string $card $this->expectExceptionMessage(sprintf( <<<'EXCEPTION' Context: Trying to hydrate enum property "%s::$suit" -Problem: Case "invalid" is not listed in enum "Doctrine\Tests\Models\Enums\Suit" +Problem: Case "Z" is not listed in enum "Doctrine\Tests\Models\Enums\Suit" Solution: Either add the case to the enum type or migrate the database column to use another case of the enum EXCEPTION , @@ -447,13 +455,16 @@ public function testEnumWithNonMatchingDatabaseValueThrowsException(string $card $this->_em->find($cardClass, $card->id); } - /** @return array */ - public static function provideCardClasses(): array + /** @return iterable */ + public static function provideCardClasses(): iterable { - return [ - Card::class => [Card::class], - TypedCard::class => [TypedCard::class], - ]; + yield Card::class => [Card::class]; + yield TypedCard::class => [TypedCard::class]; + + if (class_exists(EnumType::class)) { + yield CardNativeEnum::class => [CardNativeEnum::class]; + yield TypedCardNativeEnum::class => [TypedCardNativeEnum::class]; + } } public function testItAllowsReadingAttributes(): void 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/Functional/OneToOneUnidirectionalAssociationTest.php b/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php index cdb34bfcdbf..b2baaaa254e 100644 --- a/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php +++ b/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php @@ -5,6 +5,7 @@ namespace Doctrine\Tests\ORM\Functional; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query; use Doctrine\Tests\Models\ECommerce\ECommerceProduct; use Doctrine\Tests\Models\ECommerce\ECommerceShipping; use Doctrine\Tests\OrmFunctionalTestCase; @@ -78,6 +79,19 @@ public function testLazyLoadsObjects(): void self::assertEquals(1, $product->getShipping()->getDays()); } + public function testDoesNotLazyLoadObjectsIfConfigurationDoesNotAllowIt(): void + { + $this->createFixture(); + + $query = $this->_em->createQuery('select p from Doctrine\Tests\Models\ECommerce\ECommerceProduct p'); + $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); + + $result = $query->getResult(); + $product = $result[0]; + + self::assertNull($product->getShipping()); + } + protected function createFixture(): void { $product = new ECommerceProduct(); 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/PostLoadEventTest.php b/tests/Tests/ORM/Functional/PostLoadEventTest.php index cb7a98ab1a4..b68a12a532d 100644 --- a/tests/Tests/ORM/Functional/PostLoadEventTest.php +++ b/tests/Tests/ORM/Functional/PostLoadEventTest.php @@ -136,6 +136,27 @@ public function testLoadedProxyEntityShouldTriggerEvent(): void $userProxy->getName(); } + public function testLoadedProxyPartialShouldTriggerEvent(): void + { + $eventManager = $this->_em->getEventManager(); + + // Should not be invoked during getReference call + $mockListener = $this->createMock(PostLoadListener::class); + + // CmsUser (partially loaded), CmsAddress (inverse ToOne), 2 CmsPhonenumber + $mockListener + ->expects(self::exactly(4)) + ->method('postLoad') + ->will(self::returnValue(true)); + + $eventManager->addEventListener([Events::postLoad], $mockListener); + + $query = $this->_em->createQuery('SELECT PARTIAL u.{id, name}, p FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.phonenumbers p WHERE u.id = :id'); + + $query->setParameter('id', $this->userId); + $query->getResult(); + } + public function testLoadedProxyAssociationToOneShouldTriggerEvent(): void { $user = $this->_em->find(CmsUser::class, $this->userId); 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/SecondLevelCacheQueryCacheTest.php b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php index a3350aa5e6c..e2dec649342 100644 --- a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php +++ b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php @@ -1086,6 +1086,31 @@ public function testHintClearEntityRegionDeleteStatement(): void self::assertFalse($this->cache->containsEntity(Country::class, $this->countries[1]->getId())); } + public function testCacheablePartialQueryException(): void + { + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Second level cache does not support partial entities.'); + $this->evictRegions(); + $this->loadFixturesCountries(); + + $this->_em->createQuery('SELECT PARTIAL c.{id} FROM Doctrine\Tests\Models\Cache\Country c') + ->setCacheable(true) + ->getResult(); + } + + public function testCacheableForcePartialLoadHintQueryException(): void + { + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Second level cache does not support partial entities.'); + $this->evictRegions(); + $this->loadFixturesCountries(); + + $this->_em->createQuery('SELECT c FROM Doctrine\Tests\Models\Cache\Country c') + ->setCacheable(true) + ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true) + ->getResult(); + } + public function testNonCacheableQueryDeleteStatementException(): void { $this->expectException(CacheException::class); diff --git a/tests/Tests/ORM/Functional/Ticket/DDC163Test.php b/tests/Tests/ORM/Functional/Ticket/DDC163Test.php index 83aebb4dcae..6c0c7c94fe3 100644 --- a/tests/Tests/ORM/Functional/Ticket/DDC163Test.php +++ b/tests/Tests/ORM/Functional/Ticket/DDC163Test.php @@ -46,7 +46,7 @@ public function testQueryWithOrConditionUsingTwoRelationOnSameEntity(): void $this->_em->flush(); $this->_em->clear(); - $dql = 'SELECT person.name as person_name, spouse.name as spouse_name,friend.name as friend_name + $dql = 'SELECT PARTIAL person.{id,name}, PARTIAL spouse.{id,name}, PARTIAL friend.{id,name} FROM Doctrine\Tests\Models\Company\CompanyPerson person LEFT JOIN person.spouse spouse LEFT JOIN person.friends friend diff --git a/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php b/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php new file mode 100644 index 00000000000..b8dfba13b9c --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php @@ -0,0 +1,76 @@ +useModelSet('legacy'); + + parent::setUp(); + + $this->loadFixture(); + } + + #[Group('DDC-2519')] + public function testIssue(): void + { + $dql = 'SELECT PARTIAL l.{_source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l'; + $result = $this->_em->createQuery($dql)->getResult(); + + self::assertCount(2, $result); + self::assertInstanceOf(LegacyUserReference::class, $result[0]); + self::assertInstanceOf(LegacyUserReference::class, $result[1]); + + self::assertInstanceOf(LegacyUser::class, $result[0]->source()); + self::assertInstanceOf(LegacyUser::class, $result[0]->target()); + self::assertInstanceOf(LegacyUser::class, $result[1]->source()); + self::assertInstanceOf(LegacyUser::class, $result[1]->target()); + + self::assertTrue($this->isUninitializedObject($result[0]->target())); + self::assertTrue($this->isUninitializedObject($result[0]->source())); + self::assertTrue($this->isUninitializedObject($result[1]->target())); + self::assertTrue($this->isUninitializedObject($result[1]->source())); + + self::assertNotNull($result[0]->source()->getId()); + self::assertNotNull($result[0]->target()->getId()); + self::assertNotNull($result[1]->source()->getId()); + self::assertNotNull($result[1]->target()->getId()); + } + + public function loadFixture(): void + { + $user1 = new LegacyUser(); + $user1->username = 'FabioBatSilva'; + $user1->name = 'Fabio B. Silva'; + + $user2 = new LegacyUser(); + $user2->username = 'doctrinebot'; + $user2->name = 'Doctrine Bot'; + + $user3 = new LegacyUser(); + $user3->username = 'test'; + $user3->name = 'Tester'; + + $this->_em->persist($user1); + $this->_em->persist($user2); + $this->_em->persist($user3); + + $this->_em->flush(); + + $this->_em->persist(new LegacyUserReference($user1, $user2, 'foo')); + $this->_em->persist(new LegacyUserReference($user1, $user3, 'bar')); + + $this->_em->flush(); + $this->_em->clear(); + } +} 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/Functional/Ticket/GH8443Test.php b/tests/Tests/ORM/Functional/Ticket/GH8443Test.php index 0b5352a6ea8..44695f1856e 100644 --- a/tests/Tests/ORM/Functional/Ticket/GH8443Test.php +++ b/tests/Tests/ORM/Functional/Ticket/GH8443Test.php @@ -14,6 +14,9 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\Table; +use Doctrine\ORM\Query; +use Doctrine\Tests\Models\Company\CompanyManager; +use Doctrine\Tests\Models\Company\CompanyPerson; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\Group; @@ -30,6 +33,35 @@ protected function setUp(): void $this->createSchemaForModels(GH8443Foo::class); } + #[Group('GH-8443')] + public function testJoinRootEntityWithForcePartialLoad(): void + { + $person = new CompanyPerson(); + $person->setName('John'); + + $manager = new CompanyManager(); + $manager->setName('Adam'); + $manager->setSalary(1000); + $manager->setDepartment('IT'); + $manager->setTitle('manager'); + + $manager->setSpouse($person); + + $this->_em->persist($person); + $this->_em->persist($manager); + $this->_em->flush(); + $this->_em->clear(); + + $manager = $this->_em->createQuery( + "SELECT m from Doctrine\Tests\Models\Company\CompanyManager m + JOIN m.spouse s + WITH s.name = 'John'", + )->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)->getSingleResult(); + $this->_em->refresh($manager); + + $this->assertEquals('John', $manager->getSpouse()->getName()); + } + #[Group('GH-8443')] public function testJoinRootEntityWithOnlyOneEntityInHierarchy(): void { diff --git a/tests/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Tests/ORM/Functional/ValueObjectsTest.php index 6656d916ee0..3186e71d357 100644 --- a/tests/Tests/ORM/Functional/ValueObjectsTest.php +++ b/tests/Tests/ORM/Functional/ValueObjectsTest.php @@ -209,6 +209,58 @@ public function testDqlOnEmbeddedObjectsField(): void self::assertNull($this->_em->find(DDC93Person::class, $person->id)); } + public function testPartialDqlOnEmbeddedObjectsField(): void + { + $person = new DDC93Person('Karl', new DDC93Address('Foo', '12345', 'Gosport', new DDC93Country('England'))); + $this->_em->persist($person); + $this->_em->flush(); + $this->_em->clear(); + + // Prove that the entity was persisted correctly. + $dql = 'SELECT p FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name'; + + $person = $this->_em->createQuery($dql) + ->setParameter('name', 'Karl') + ->getSingleResult(); + + self::assertEquals('Gosport', $person->address->city); + self::assertEquals('Foo', $person->address->street); + self::assertEquals('12345', $person->address->zip); + self::assertEquals('England', $person->address->country->name); + + // Clear the EM and prove that the embeddable can be the subject of a partial query. + $this->_em->clear(); + + $dql = 'SELECT PARTIAL p.{id,address.city} FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name'; + + $person = $this->_em->createQuery($dql) + ->setParameter('name', 'Karl') + ->getSingleResult(); + + // Selected field must be equal, all other fields must be null. + self::assertEquals('Gosport', $person->address->city); + self::assertNull($person->address->street); + self::assertNull($person->address->zip); + self::assertNull($person->address->country); + self::assertNull($person->name); + + // Clear the EM and prove that the embeddable can be the subject of a partial query regardless of attributes positions. + $this->_em->clear(); + + $dql = 'SELECT PARTIAL p.{address.city, id} FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name'; + + $person = $this->_em->createQuery($dql) + ->setParameter('name', 'Karl') + ->getSingleResult(); + + // Selected field must be equal, all other fields must be null. + self::assertEquals('Gosport', $person->address->city); + self::assertNull($person->address->street); + self::assertNull($person->address->zip); + self::assertNull($person->address->country); + self::assertNull($person->name); + } + public function testDqlWithNonExistentEmbeddableField(): void { $this->expectException(QueryException::class); @@ -224,7 +276,7 @@ public function testPartialDqlWithNonExistentEmbeddableField(): void $this->expectExceptionMessage("no mapped field named 'address.asdfasdf'"); $this->_em->createQuery('SELECT PARTIAL p.{id,address.asdfasdf} FROM ' . __NAMESPACE__ . '\\DDC93Person p') - ->getArrayResult(); + ->execute(); } public function testEmbeddableWithInheritance(): void 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); - } } 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/Query/LanguageRecognitionTest.php b/tests/Tests/ORM/Query/LanguageRecognitionTest.php index 448b425b64e..31181de1a44 100644 --- a/tests/Tests/ORM/Query/LanguageRecognitionTest.php +++ b/tests/Tests/ORM/Query/LanguageRecognitionTest.php @@ -533,13 +533,11 @@ public function testUnknownAbstractSchemaName(): void public function testCorrectPartialObjectLoad(): void { - $this->hydrationMode = AbstractQuery::HYDRATE_ARRAY; $this->assertValidDQL('SELECT PARTIAL u.{id,name} FROM Doctrine\Tests\Models\CMS\CmsUser u'); } public function testIncorrectPartialObjectLoadBecauseOfMissingIdentifier(): void { - $this->hydrationMode = AbstractQuery::HYDRATE_ARRAY; $this->assertInvalidDQL('SELECT PARTIAL u.{name} FROM Doctrine\Tests\Models\CMS\CmsUser u'); } diff --git a/tests/Tests/ORM/Query/ParserTest.php b/tests/Tests/ORM/Query/ParserTest.php index 6290bbc4dab..430177b1fcc 100644 --- a/tests/Tests/ORM/Query/ParserTest.php +++ b/tests/Tests/ORM/Query/ParserTest.php @@ -4,7 +4,6 @@ namespace Doctrine\Tests\ORM\Query; -use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Query; use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\QueryException; @@ -116,15 +115,6 @@ public function testNullLookahead(): void $parser->match(TokenType::T_SELECT); } - public function testPartialExpressionWithObjectHydratorThrows(): void - { - $this->expectException(HydrationException::class); - $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); - - $parser = $this->createParser(CmsUser::class); - $parser->PartialObjectExpression(); - } - private function createParser(string $dql): Parser { $query = new Query($this->getTestEntityManager()); diff --git a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php index 87eb104df10..777ca79b437 100644 --- a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php +++ b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php @@ -1326,8 +1326,6 @@ public function testIdentityFunctionWithCompositePrimaryKey(): void #[Group('DDC-2519')] public function testPartialWithAssociationIdentifier(): void { - $this->hydrationMode = ORMQuery::HYDRATE_ARRAY; - $this->assertSqlGeneration( 'SELECT PARTIAL l.{_source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l', 'SELECT l0_.iUserIdSource AS iUserIdSource_0, l0_.iUserIdTarget AS iUserIdTarget_1 FROM legacy_users_reference l0_', @@ -1373,58 +1371,122 @@ public function testIdentityFunctionDoesNotAcceptStateField(): void } #[Group('DDC-1389')] - public function testInheritanceTypeJoinInRootClass(): void + public function testInheritanceTypeJoinInRootClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT p FROM Doctrine\Tests\Models\Company\CompanyPerson p', 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.title AS title_2, c2_.salary AS salary_3, c2_.department AS department_4, c2_.startDate AS startDate_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c1_.car_id AS car_id_8 FROM company_persons c0_ LEFT JOIN company_managers c1_ ON c0_.id = c1_.id LEFT JOIN company_employees c2_ ON c0_.id = c2_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], ); } #[Group('DDC-1389')] - public function testInheritanceTypeJoinInChildClass(): void + public function testInheritanceTypeJoinInRootClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT p FROM Doctrine\Tests\Models\Company\CompanyPerson p', + 'SELECT c0_.id AS id_0, c0_.name AS name_1, c0_.discr AS discr_2 FROM company_persons c0_', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeJoinInChildClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT e FROM Doctrine\Tests\Models\Company\CompanyEmployee e', 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c2_.car_id AS car_id_8 FROM company_employees c1_ INNER JOIN company_persons c0_ ON c1_.id = c0_.id LEFT JOIN company_managers c2_ ON c1_.id = c2_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeJoinInChildClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT e FROM Doctrine\Tests\Models\Company\CompanyEmployee e', + 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c0_.discr AS discr_5 FROM company_employees c1_ INNER JOIN company_persons c0_ ON c1_.id = c0_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] - public function testInheritanceTypeJoinInLeafClass(): void + public function testInheritanceTypeJoinInLeafClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT m FROM Doctrine\Tests\Models\Company\CompanyManager m', 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c2_.car_id AS car_id_8 FROM company_managers c2_ INNER JOIN company_employees c1_ ON c2_.id = c1_.id INNER JOIN company_persons c0_ ON c2_.id = c0_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeJoinInLeafClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT m FROM Doctrine\Tests\Models\Company\CompanyManager m', + 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6 FROM company_managers c2_ INNER JOIN company_employees c1_ ON c2_.id = c1_.id INNER JOIN company_persons c0_ ON c2_.id = c0_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] - public function testInheritanceTypeSingleTableInRootClass(): void + public function testInheritanceTypeSingleTableInRootClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT c FROM Doctrine\Tests\Models\Company\CompanyContract c', "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.fixPrice AS fixPrice_2, c0_.hoursWorked AS hoursWorked_3, c0_.pricePerHour AS pricePerHour_4, c0_.maxPrice AS maxPrice_5, c0_.discr AS discr_6, c0_.salesPerson_id AS salesPerson_id_7 FROM company_contracts c0_ WHERE c0_.discr IN ('fix', 'flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeSingleTableInRootClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT c FROM Doctrine\Tests\Models\Company\CompanyContract c', + "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.fixPrice AS fixPrice_2, c0_.hoursWorked AS hoursWorked_3, c0_.pricePerHour AS pricePerHour_4, c0_.maxPrice AS maxPrice_5, c0_.discr AS discr_6 FROM company_contracts c0_ WHERE c0_.discr IN ('fix', 'flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] public function testInheritanceTypeSingleTableInChildClassWithDisabledForcePartialLoad(): void { - $this->hydrationMode = ORMQuery::HYDRATE_ARRAY; + $this->assertSqlGeneration( + 'SELECT fc FROM Doctrine\Tests\Models\Company\CompanyFlexContract fc', + "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5, c0_.salesPerson_id AS salesPerson_id_6 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + #[Group('DDC-1389')] + public function testInheritanceTypeSingleTableInChildClassWithEnabledForcePartialLoad(): void + { $this->assertSqlGeneration( 'SELECT fc FROM Doctrine\Tests\Models\Company\CompanyFlexContract fc', "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] - public function testInheritanceTypeSingleTableInLeafClass(): void + public function testInheritanceTypeSingleTableInLeafClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT fuc FROM Doctrine\Tests\Models\Company\CompanyFlexUltraContract fuc', "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5, c0_.salesPerson_id AS salesPerson_id_6 FROM company_contracts c0_ WHERE c0_.discr IN ('flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeSingleTableInLeafClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT fuc FROM Doctrine\Tests\Models\Company\CompanyFlexUltraContract fuc', + "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5 FROM company_contracts c0_ WHERE c0_.discr IN ('flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } @@ -1703,8 +1765,6 @@ public function testCustomTypeValueSqlForAllFields(): void public function testCustomTypeValueSqlForPartialObject(): void { - $this->hydrationMode = ORMQuery::HYDRATE_ARRAY; - if (DBALType::hasType('negative_to_positive')) { DBALType::overrideType('negative_to_positive', NegativeToPositiveType::class); } else { @@ -1713,7 +1773,7 @@ public function testCustomTypeValueSqlForPartialObject(): void $this->assertSqlGeneration( 'SELECT partial p.{id, customInteger} FROM Doctrine\Tests\Models\CustomType\CustomTypeParent p', - 'SELECT c0_.id AS id_0, -(c0_.customInteger) AS customInteger_1 FROM customtype_parents c0_', + 'SELECT c0_.id AS id_0, -(c0_.customInteger) AS customInteger_1, c0_.child_id AS child_id_2 FROM customtype_parents c0_', ); } diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php index 0f5bac25b72..2aeca48dd5d 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 { @@ -137,7 +138,7 @@ public function testCountQueryWithComplexScalarOrderByItemWithoutJoin(): void ); } - public function testCountQueryWithComplexScalarOrderByItemJoined(): void + public function testCountQueryWithComplexScalarOrderByItemJoinedWithoutPartial(): void { $this->entityManager = $this->createTestEntityManagerWithPlatform(new MySQLPlatform()); @@ -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 12cc7076147..483fa6b7f4d 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -12,7 +12,6 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\Exception\EntityIdentityCollisionException; -use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; @@ -22,7 +21,6 @@ use Doctrine\ORM\Mapping\Version; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMInvalidArgumentException; -use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\UnitOfWork; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Mocks\EntityPersisterMock; @@ -33,6 +31,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 stdClass; @@ -647,13 +646,45 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void $this->_unitOfWork->persist($phone2); } - public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): void + public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void { - $this->expectException(HydrationException::class); - $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); + $driver = $this->createStub(Driver::class); + $driver->method('connect') + ->willReturn($this->createMock(Driver\Connection::class)); + + $connection = new class (['platform' => $this->createStub(AbstractPlatform::class)], $driver) 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); - $hints = [SqlWalker::HINT_PARTIAL => true]; - $this->_unitOfWork->createEntity(VersionedAssignedIdentifierEntity::class, ['id' => 1], $hints); + // 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()); + } } } @@ -661,60 +692,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() { @@ -745,9 +768,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() { @@ -763,9 +785,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() {