From d5fdd676f4a807b6be37d5e18f9a9e3adea48850 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:20:34 +0100 Subject: [PATCH 01/31] Reintroduce PARTIAL, but only for non-object hydration. --- UPGRADE.md | 8 +- docs/en/index.rst | 1 + .../reference/dql-doctrine-query-language.rst | 23 +++- docs/en/reference/partial-hydration.rst | 20 +++ docs/en/sidebar.rst | 1 + src/Internal/Hydration/HydrationException.php | 5 + src/Query.php | 8 ++ src/Query/AST/PartialObjectExpression.php | 15 +++ src/Query/Parser.php | 118 +++++++++++++++++- src/Query/SqlWalker.php | 27 +++- src/Query/TokenType.php | 1 + src/UnitOfWork.php | 6 + .../Tests/ORM/Functional/ValueObjectsTest.php | 9 ++ .../ORM/Query/LanguageRecognitionTest.php | 15 +++ .../ORM/Query/SelectSqlGenerationTest.php | 40 +++++- .../LimitSubqueryOutputWalkerTest.php | 17 +++ 16 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 docs/en/reference/partial-hydration.rst create mode 100644 src/Query/AST/PartialObjectExpression.php diff --git a/UPGRADE.md b/UPGRADE.md index 7d49322ca8e..85a492d7758 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -75,9 +75,11 @@ now they throw an exception. ## BC BREAK: Partial objects are removed -- The `PARTIAL` keyword in DQL no longer exists. -- `Doctrine\ORM\Query\AST\PartialObjectExpression`is removed. -- `Doctrine\ORM\Query\SqlWalker::HINT_PARTIAL` and +WARNING: This was relaxed in ORM 3.2 when partial was re-allowed for array-hydration. + +- The `PARTIAL` keyword in DQL no longer exists (reintroduced in ORM 3.2) +- `Doctrine\ORM\Query\AST\PartialObjectExpression` is removed. (reintroduced in ORM 3.2) +- `Doctrine\ORM\Query\SqlWalker::HINT_PARTIAL` (reintroduced in ORM 3.2) and `Doctrine\ORM\Query::HINT_FORCE_PARTIAL_LOAD` are removed. - `Doctrine\ORM\EntityManager*::getPartialReference()` is removed. diff --git a/docs/en/index.rst b/docs/en/index.rst index 83071ad9597..4d23062cd0d 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -73,6 +73,7 @@ Advanced Topics * :doc:`TypedFieldMapper ` * :doc:`Improving Performance ` * :doc:`Caching ` +* :doc:`Partial Hydration ` * :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 80d41f17002..b9adecd27b8 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -523,6 +523,25 @@ when the DQL is switched to an arbitrary join. - HAVING is applied to the results of a query after aggregation (GROUP BY) + +Partial Hydration Syntax +^^^^^^^^^^^^^^^^^^^^^^^^ + +By default when you run a DQL query in Doctrine and select only a +subset of the fields for a given entity, you do not receive objects +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: + +.. 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 + "NEW" Operator Syntax ^^^^^^^^^^^^^^^^^^^^^ @@ -1647,8 +1666,10 @@ Select Expressions .. code-block:: php - SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable] + SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable] SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable] + PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet + PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" NewObjectArg ::= ScalarExpression | "(" Subselect ")" diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst new file mode 100644 index 00000000000..a2cca3d186a --- /dev/null +++ b/docs/en/reference/partial-hydration.rst @@ -0,0 +1,20 @@ +Partial Hydration +================= + +.. note:: + + Creating Partial Objects through DQL was possible in ORM 2, + but is only supported for array hydration as of ORM 3 anymore. + +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. + +.. code-block:: php + + createQuery("SELECT PARTIAL u.{id,name}, partial a.{id,street} FROM MyApp\Domain\User u JOIN u.addresses a") + ->getArrayResult(); + +This is a useful optimization when you are not interested in all fields of an entity +for performance reasons, for example in use-cases for exporting or rendering lots of data. diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 619c6e2511a..628aecfceab 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -43,6 +43,7 @@ reference/query-builder reference/native-sql reference/change-tracking-policies + reference/partial-hydration reference/attributes-reference reference/xml-mapping reference/php-mapping diff --git a/src/Internal/Hydration/HydrationException.php b/src/Internal/Hydration/HydrationException.php index 710114f7e6b..a59644300b4 100644 --- a/src/Internal/Hydration/HydrationException.php +++ b/src/Internal/Hydration/HydrationException.php @@ -64,4 +64,9 @@ public static function invalidDiscriminatorValue(string $discrValue, array $disc implode('", "', $discrValues), )); } + + public static function partialObjectHydrationDisallowed(): self + { + return new self('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); + } } diff --git a/src/Query.php b/src/Query.php index 5b0ceb7a97c..8c44eba8afa 100644 --- a/src/Query.php +++ b/src/Query.php @@ -70,6 +70,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. diff --git a/src/Query/AST/PartialObjectExpression.php b/src/Query/AST/PartialObjectExpression.php new file mode 100644 index 00000000000..875f4453fe1 --- /dev/null +++ b/src/Query/AST/PartialObjectExpression.php @@ -0,0 +1,15 @@ + */ private array $deferredIdentificationVariables = []; + /** @psalm-var list */ + private array $deferredPartialObjectExpressions = []; + /** @psalm-var list */ private array $deferredPathExpressions = []; @@ -224,6 +229,10 @@ public function getAST(): AST\SelectStatement|AST\UpdateStatement|AST\DeleteStat // This also allows post-processing of the AST for modification purposes. $this->processDeferredIdentificationVariables(); + if ($this->deferredPartialObjectExpressions) { + $this->processDeferredPartialObjectExpressions(); + } + if ($this->deferredPathExpressions) { $this->processDeferredPathExpressions(); } @@ -599,6 +608,44 @@ private function processDeferredNewObjectExpressions(AST\SelectStatement $AST): } } + /** + * Validates that the given PartialObjectExpression is semantically correct. + * It must exist in query components list. + */ + private function processDeferredPartialObjectExpressions(): void + { + foreach ($this->deferredPartialObjectExpressions as $deferredItem) { + $expr = $deferredItem['expression']; + $class = $this->getMetadataForDqlAlias($expr->identificationVariable); + + foreach ($expr->partialFieldSet as $field) { + if (isset($class->fieldMappings[$field])) { + continue; + } + + if ( + isset($class->associationMappings[$field]) && + $class->associationMappings[$field]->isToOneOwningSide() + ) { + continue; + } + + $this->semanticalError(sprintf( + "There is no mapped field named '%s' on class %s.", + $field, + $class->name, + ), $deferredItem['token']); + } + + if (array_intersect($class->identifier, $expr->partialFieldSet) !== $class->identifier) { + $this->semanticalError( + 'The partial field selection of class ' . $class->name . ' must contain the identifier.', + $deferredItem['token'], + ); + } + } + } + /** * Validates that the given ResultVariable is semantically correct. * It must exist in query components list. @@ -1621,6 +1668,67 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration return new AST\JoinAssociationDeclaration($joinAssociationPathExpression, $aliasIdentificationVariable, $indexBy); } + /** + * PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet + * PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" + */ + public function PartialObjectExpression(): AST\PartialObjectExpression + { + if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) { + throw HydrationException::partialObjectHydrationDisallowed(); + } + + $this->match(TokenType::T_PARTIAL); + + $partialFieldSet = []; + + $identificationVariable = $this->IdentificationVariable(); + + $this->match(TokenType::T_DOT); + $this->match(TokenType::T_OPEN_CURLY_BRACE); + $this->match(TokenType::T_IDENTIFIER); + + assert($this->lexer->token !== null); + $field = $this->lexer->token->value; + + // First field in partial expression might be embeddable property + while ($this->lexer->isNextToken(TokenType::T_DOT)) { + $this->match(TokenType::T_DOT); + $this->match(TokenType::T_IDENTIFIER); + $field .= '.' . $this->lexer->token->value; + } + + $partialFieldSet[] = $field; + + while ($this->lexer->isNextToken(TokenType::T_COMMA)) { + $this->match(TokenType::T_COMMA); + $this->match(TokenType::T_IDENTIFIER); + + $field = $this->lexer->token->value; + + while ($this->lexer->isNextToken(TokenType::T_DOT)) { + $this->match(TokenType::T_DOT); + $this->match(TokenType::T_IDENTIFIER); + $field .= '.' . $this->lexer->token->value; + } + + $partialFieldSet[] = $field; + } + + $this->match(TokenType::T_CLOSE_CURLY_BRACE); + + $partialObjectExpression = new AST\PartialObjectExpression($identificationVariable, $partialFieldSet); + + // Defer PartialObjectExpression validation + $this->deferredPartialObjectExpressions[] = [ + 'expression' => $partialObjectExpression, + 'nestingLevel' => $this->nestingLevel, + 'token' => $this->lexer->token, + ]; + + return $partialObjectExpression; + } + /** * NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" */ @@ -1920,7 +2028,7 @@ public function SimpleWhenClause(): AST\SimpleWhenClause /** * SelectExpression ::= ( * IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | - * "(" Subselect ")" | CaseExpression | NewObjectExpression + * PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression * ) [["AS"] ["HIDDEN"] AliasResultVariable] */ public function SelectExpression(): AST\SelectExpression @@ -1961,6 +2069,12 @@ public function SelectExpression(): AST\SelectExpression break; + // PartialObjectExpression (PARTIAL u.{id, name}) + case $lookaheadType === TokenType::T_PARTIAL: + $expression = $this->PartialObjectExpression(); + $identVariable = $expression->identificationVariable; + break; + // Subselect case $lookaheadType === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT: $this->match(TokenType::T_OPEN_PARENTHESIS); @@ -1986,7 +2100,7 @@ public function SelectExpression(): AST\SelectExpression default: $this->syntaxError( - 'IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression', + 'IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression', $this->lexer->lookahead, ); } diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 018c2455e49..b70e37a03b3 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -28,6 +28,7 @@ use function assert; use function count; use function implode; +use function in_array; use function is_array; use function is_float; use function is_numeric; @@ -51,6 +52,11 @@ class SqlWalker public const HINT_DISTINCT = 'doctrine.distinct'; + /** + * Used to mark a query as containing a PARTIAL expression, which needs to be known by SLC. + */ + public const HINT_PARTIAL = 'doctrine.partial'; + private readonly ResultSetMapping $rsm; /** @@ -1318,7 +1324,17 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st break; default: - $dqlAlias = $expr; + // IdentificationVariable or PartialObjectExpression + if ($expr instanceof AST\PartialObjectExpression) { + $this->query->setHint(self::HINT_PARTIAL, true); + + $dqlAlias = $expr->identificationVariable; + $partialFieldSet = $expr->partialFieldSet; + } else { + $dqlAlias = $expr; + $partialFieldSet = []; + } + $class = $this->getMetadataForDqlAlias($dqlAlias); $resultAlias = $selectExpression->fieldIdentificationVariable ?: null; @@ -1334,6 +1350,10 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // Select all fields from the queried class foreach ($class->fieldMappings as $fieldName => $mapping) { + if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) { + continue; + } + $tableName = isset($mapping->inherited) ? $this->em->getClassMetadata($mapping->inherited)->getTableName() : $class->getTableName(); @@ -1360,13 +1380,14 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // Add any additional fields of subclasses (excluding inherited fields) // 1) on Single Table Inheritance: always, since its marginal overhead - // 2) on Class Table Inheritance + // 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); foreach ($subClass->fieldMappings as $fieldName => $mapping) { - if (isset($mapping->inherited)) { + if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { continue; } diff --git a/src/Query/TokenType.php b/src/Query/TokenType.php index e745e4a1da3..bf1c351c2a6 100644 --- a/src/Query/TokenType.php +++ b/src/Query/TokenType.php @@ -77,6 +77,7 @@ enum TokenType: int case T_OR = 242; case T_ORDER = 243; case T_OUTER = 244; + case T_PARTIAL = 245; case T_SELECT = 246; case T_SET = 247; case T_SOME = 248; diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index e1336140640..ec7175c5df4 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -28,6 +28,7 @@ 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; @@ -43,6 +44,7 @@ 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; @@ -2353,6 +2355,10 @@ 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/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Tests/ORM/Functional/ValueObjectsTest.php index 08ce4b17d89..6656d916ee0 100644 --- a/tests/Tests/ORM/Functional/ValueObjectsTest.php +++ b/tests/Tests/ORM/Functional/ValueObjectsTest.php @@ -218,6 +218,15 @@ public function testDqlWithNonExistentEmbeddableField(): void ->execute(); } + public function testPartialDqlWithNonExistentEmbeddableField(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage("no mapped field named 'address.asdfasdf'"); + + $this->_em->createQuery('SELECT PARTIAL p.{id,address.asdfasdf} FROM ' . __NAMESPACE__ . '\\DDC93Person p') + ->getArrayResult(); + } + public function testEmbeddableWithInheritance(): void { $car = new DDC93Car(new DDC93Address('Foo', '12345', 'Asdf')); diff --git a/tests/Tests/ORM/Query/LanguageRecognitionTest.php b/tests/Tests/ORM/Query/LanguageRecognitionTest.php index 0bdf2f37b38..ec57b1f1382 100644 --- a/tests/Tests/ORM/Query/LanguageRecognitionTest.php +++ b/tests/Tests/ORM/Query/LanguageRecognitionTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM\Query; +use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; @@ -22,6 +23,7 @@ class LanguageRecognitionTest extends OrmTestCase { private EntityManagerInterface $entityManager; + private int $hydrationMode = AbstractQuery::HYDRATE_OBJECT; protected function setUp(): void { @@ -45,6 +47,7 @@ public function parseDql(string $dql, array $hints = []): ParserResult { $query = $this->entityManager->createQuery($dql); $query->setDQL($dql); + $query->setHydrationMode($this->hydrationMode); foreach ($hints as $key => $value) { $query->setHint($key, $value); @@ -527,6 +530,18 @@ public function testUnknownAbstractSchemaName(): void $this->assertInvalidDQL('SELECT u FROM UnknownClassName u'); } + 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'); + } + public function testScalarExpressionInSelect(): void { $this->assertValidDQL('SELECT u, 42 + u.id AS someNumber FROM Doctrine\Tests\Models\CMS\CmsUser u'); diff --git a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php index 681562c1dd8..3969fe51636 100644 --- a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php +++ b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php @@ -40,6 +40,7 @@ class_exists('Doctrine\\DBAL\\Platforms\\SqlitePlatform'); class SelectSqlGenerationTest extends OrmTestCase { private EntityManagerInterface $entityManager; + private int $hydrationMode = ORMQuery::HYDRATE_OBJECT; protected function setUp(): void { @@ -56,6 +57,7 @@ public function assertSqlGeneration( array $queryParams = [], ): void { $query = $this->entityManager->createQuery($dqlToBeTested); + $query->setHydrationMode($this->hydrationMode); foreach ($queryParams as $name => $value) { $query->setParameter($name, $value); @@ -1330,6 +1332,22 @@ 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_', + ); + + $this->assertSqlGeneration( + 'SELECT PARTIAL l.{_description, _source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l', + 'SELECT l0_.description AS description_0, l0_.iUserIdSource AS iUserIdSource_1, l0_.iUserIdTarget AS iUserIdTarget_2 FROM legacy_users_reference l0_', + ); + } + #[Group('DDC-1339')] public function testIdentityFunctionInSelectClause(): void { @@ -1400,11 +1418,13 @@ public function testInheritanceTypeSingleTableInRootClass(): void } #[Group('DDC-1389')] - public function testInheritanceTypeSingleTableInChildClass(): void + 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')", + "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')", ); } @@ -1690,6 +1710,22 @@ 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 { + DBALType::addType('negative_to_positive', NegativeToPositiveType::class); + } + + $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_', + ); + } + #[Group('DDC-1529')] public function testMultipleFromAndInheritanceCondition(): void { diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php index 65d0dbabf23..0f5bac25b72 100644 --- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php @@ -147,6 +147,23 @@ public function testCountQueryWithComplexScalarOrderByItemJoined(): void ); } + public function testCountQueryWithComplexScalarOrderByItemJoinedWithPartial(): void + { + $entityManager = $this->createTestEntityManagerWithPlatform(new MySQLPlatform()); + + $query = $entityManager->createQuery( + 'SELECT u, partial a.{id, imageAltDesc} FROM Doctrine\Tests\ORM\Tools\Pagination\User u JOIN u.avatar a ORDER BY a.imageHeight * a.imageWidth DESC', + ); + + $query->setHydrationMode(Query::HYDRATE_ARRAY); + $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, LimitSubqueryOutputWalker::class); + + self::assertSame( + 'SELECT DISTINCT id_0 FROM (SELECT DISTINCT id_0, imageHeight_5 * imageWidth_6 FROM (SELECT u0_.id AS id_0, a1_.id AS id_1, a1_.imageAltDesc AS imageAltDesc_2, a1_.id AS id_3, a1_.image AS image_4, a1_.imageHeight AS imageHeight_5, a1_.imageWidth AS imageWidth_6, a1_.imageAltDesc AS imageAltDesc_7 FROM User u0_ INNER JOIN Avatar a1_ ON u0_.id = a1_.user_id) dctrn_result_inner ORDER BY imageHeight_5 * imageWidth_6 DESC) dctrn_result', + $query->getSQL(), + ); + } + public function testCountQueryWithComplexScalarOrderByItemOracle(): void { $this->entityManager = $this->createTestEntityManagerWithPlatform(new OraclePlatform()); From eb8510ff5c7c4ca508556b135ed194a0e3318e1e Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:34:54 +0100 Subject: [PATCH 02/31] Add tests for adjusted functionality. --- tests/Tests/ORM/Functional/QueryTest.php | 46 ++++++++++++++++++++++++ tests/Tests/ORM/Query/ParserTest.php | 10 ++++++ tests/Tests/ORM/UnitOfWorkTest.php | 11 ++++++ 3 files changed, 67 insertions(+) diff --git a/tests/Tests/ORM/Functional/QueryTest.php b/tests/Tests/ORM/Functional/QueryTest.php index 00efce3f080..01d876fbbd1 100644 --- a/tests/Tests/ORM/Functional/QueryTest.php +++ b/tests/Tests/ORM/Functional/QueryTest.php @@ -109,6 +109,52 @@ public function testJoinQueries(): void self::assertEquals('Symfony 2', $users[0]->articles[1]->topic); } + public function testJoinPartialArrayHydration(): void + { + $user = new CmsUser(); + $user->name = 'Guilherme'; + $user->username = 'gblanco'; + $user->status = 'developer'; + + $article1 = new CmsArticle(); + $article1->topic = 'Doctrine 2'; + $article1->text = 'This is an introduction to Doctrine 2.'; + $user->addArticle($article1); + + $article2 = new CmsArticle(); + $article2->topic = 'Symfony 2'; + $article2->text = 'This is an introduction to Symfony 2.'; + $user->addArticle($article2); + + $this->_em->persist($user); + $this->_em->persist($article1); + $this->_em->persist($article2); + + $this->_em->flush(); + $this->_em->clear(); + + $query = $this->_em->createQuery('select partial u.{id, username}, partial a.{id, topic} from ' . CmsUser::class . ' u join u.articles a ORDER BY a.topic'); + $users = $query->getArrayResult(); + + $this->assertEquals([ + [ + 'id' => 13, + 'username' => 'gblanco', + 'articles' => + [ + [ + 'id' => 3, + 'topic' => 'Doctrine 2', + ], + [ + 'id' => 4, + 'topic' => 'Symfony 2', + ], + ], + ], + ], $users); + } + public function testUsingZeroBasedQueryParameterShouldWork(): void { $user = new CmsUser(); diff --git a/tests/Tests/ORM/Query/ParserTest.php b/tests/Tests/ORM/Query/ParserTest.php index 430177b1fcc..6290bbc4dab 100644 --- a/tests/Tests/ORM/Query/ParserTest.php +++ b/tests/Tests/ORM/Query/ParserTest.php @@ -4,6 +4,7 @@ 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; @@ -115,6 +116,15 @@ 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/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index 550b1cfe1c8..ff5bab7895e 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -13,6 +13,7 @@ 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,6 +23,7 @@ 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; @@ -643,6 +645,15 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void $this->_unitOfWork->persist($phone2); } + + public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); + + $hints = [SqlWalker::HINT_PARTIAL => true]; + $this->_unitOfWork->createEntity(VersionedAssignedIdentifierEntity::class, ['id' => 1], $hints); + } } From 758f0d7605132869c4713dcf487e7adb2b655c0c Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:36:21 +0100 Subject: [PATCH 03/31] Remove Query::HINT_FORCE_PARTIAL_LOAD constant, not needed to be reintroduced. --- src/Query.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Query.php b/src/Query.php index 8c44eba8afa..5b0ceb7a97c 100644 --- a/src/Query.php +++ b/src/Query.php @@ -70,14 +70,6 @@ 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. From 90962f060a39bedc541d199f7196705297b10e2a Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:39:38 +0100 Subject: [PATCH 04/31] Use id dynamically in array hydration test. --- tests/Tests/ORM/Functional/QueryTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Tests/ORM/Functional/QueryTest.php b/tests/Tests/ORM/Functional/QueryTest.php index 01d876fbbd1..10e26df023f 100644 --- a/tests/Tests/ORM/Functional/QueryTest.php +++ b/tests/Tests/ORM/Functional/QueryTest.php @@ -138,16 +138,16 @@ public function testJoinPartialArrayHydration(): void $this->assertEquals([ [ - 'id' => 13, + 'id' => $user->id, 'username' => 'gblanco', 'articles' => [ [ - 'id' => 3, + 'id' => $article1->id, 'topic' => 'Doctrine 2', ], [ - 'id' => 4, + 'id' => $article2->id, 'topic' => 'Symfony 2', ], ], From 80278c545eb2679184ef549c1040bb93545c920b Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 23:36:13 +0100 Subject: [PATCH 05/31] Update docs/en/reference/partial-hydration.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- docs/en/reference/partial-hydration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst index a2cca3d186a..16879c45c52 100644 --- a/docs/en/reference/partial-hydration.rst +++ b/docs/en/reference/partial-hydration.rst @@ -4,7 +4,7 @@ Partial Hydration .. note:: Creating Partial Objects through DQL was possible in ORM 2, - but is only supported for array hydration as of ORM 3 anymore. + 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 From 68744489f023afd810b08b70c2ba582f500190a1 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Tue, 18 Jun 2024 14:15:31 +0200 Subject: [PATCH 06/31] Undeprecate PARTIAL for array hydration. (#11366) * Undeprecate PARTIAL for array hydration. * note about undeprecate partial in UPGRADE.md --- UPGRADE.md | 5 +++++ src/Query/Parser.php | 12 +++++++----- src/UnitOfWork.php | 10 ++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 5689b307bda..9e6c0cb2253 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -15,6 +15,11 @@ to find out. Use `Doctrine\ORM\Query\TokenType::T_*` instead. +## PARTIAL DQL syntax is undeprecated for non-object hydration + +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. + # Upgrade to 2.17 ## Deprecate annotations classes for named queries diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 949a8f4ebdd..eb7da7337ac 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1844,11 +1844,13 @@ public function JoinAssociationDeclaration() */ public function PartialObjectExpression() { - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/issues/8471', - 'PARTIAL syntax in DQL is deprecated.' - ); + if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/issues/8471', + 'PARTIAL syntax in DQL is deprecated for object hydration.' + ); + } $this->match(TokenType::T_PARTIAL); diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index f1affcf7ebc..7f4ce8c17c5 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -41,6 +41,7 @@ use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister; use Doctrine\ORM\Persisters\Entity\SingleTablePersister; use Doctrine\ORM\Proxy\InternalProxy; +use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Utility\IdentifierFlattener; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Doctrine\Persistence\NotifyPropertyChanged; @@ -2919,6 +2920,15 @@ private function newInstance(ClassMetadata $class) */ public function createEntity($className, array $data, &$hints = []) { + if (isset($hints[SqlWalker::HINT_PARTIAL])) { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/issues/8471', + 'Partial Objects are deprecated for object hydration (here entity %s)', + $className + ); + } + $class = $this->em->getClassMetadata($className); $id = $this->identifierFlattener->flattenIdentifier($class, $data); From 066ec1ac81c8409293763eeb1a50dc05b792fb8c Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 18 Jun 2024 14:18:37 +0200 Subject: [PATCH 07/31] Fix upgrade guide for 2.20 (#11504) --- UPGRADE.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 9e6c0cb2253..e20a8fef89b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,10 @@ +# Upgrade to 2.20 + +## PARTIAL DQL syntax is undeprecated for non-object hydration + +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. + # Upgrade to 2.19 ## Deprecate calling `ClassMetadata::getAssociationMappedByTargetField()` with the owning side of an association @@ -15,11 +22,6 @@ to find out. Use `Doctrine\ORM\Query\TokenType::T_*` instead. -## PARTIAL DQL syntax is undeprecated for non-object hydration - -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. - # Upgrade to 2.17 ## Deprecate annotations classes for named queries From 96d13ac62adf7169e36189a8547280dd400604d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bundyra?= Date: Mon, 3 Jun 2024 12:11:22 +0100 Subject: [PATCH 08/31] Fetching entities with Composite Key Relations and null values Remove redundant condition to check if target class contains foreign identifier in order to allow fetching a null for relations with composite keys, when part of the key value is null. --- src/UnitOfWork.php | 5 +- .../CompositeKeyRelations/CustomerClass.php | 33 ++++++++++ .../CompositeKeyRelations/InvoiceClass.php | 46 ++++++++++++++ .../Functional/CompositeKeyRelationsTest.php | 61 +++++++++++++++++++ tests/Tests/OrmFunctionalTestCase.php | 4 ++ 5 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 tests/Tests/Models/CompositeKeyRelations/CustomerClass.php create mode 100644 tests/Tests/Models/CompositeKeyRelations/InvoiceClass.php create mode 100644 tests/Tests/ORM/Functional/CompositeKeyRelationsTest.php diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index f1affcf7ebc..39ba6b68b7f 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -3053,10 +3053,7 @@ public function createEntity($className, array $data, &$hints = []) } else { $associatedId[$targetClass->fieldNames[$targetColumn]] = $joinColumnValue; } - } elseif ( - $targetClass->containsForeignIdentifier - && in_array($targetClass->getFieldForColumn($targetColumn), $targetClass->identifier, true) - ) { + } elseif (in_array($targetClass->getFieldForColumn($targetColumn), $targetClass->identifier, true)) { // the missing key is part of target's entity primary key $associatedId = []; break; diff --git a/tests/Tests/Models/CompositeKeyRelations/CustomerClass.php b/tests/Tests/Models/CompositeKeyRelations/CustomerClass.php new file mode 100644 index 00000000000..aca13e4c8ee --- /dev/null +++ b/tests/Tests/Models/CompositeKeyRelations/CustomerClass.php @@ -0,0 +1,33 @@ +useModelSet('compositekeyrelations'); + + parent::setUp(); + } + + public function testFindEntityWithNotNullRelation(): void + { + $this->_em->getConnection()->insert('CustomerClass', [ + 'companyCode' => 'AA', + 'code' => 'CUST1', + 'name' => 'Customer 1', + ]); + + $this->_em->getConnection()->insert('InvoiceClass', [ + 'companyCode' => 'AA', + 'invoiceNumber' => 'INV1', + 'customerCode' => 'CUST1', + ]); + + $entity = $this->findEntity('AA', 'INV1'); + self::assertSame('AA', $entity->companyCode); + self::assertSame('INV1', $entity->invoiceNumber); + self::assertInstanceOf(CustomerClass::class, $entity->customer); + self::assertSame('Customer 1', $entity->customer->name); + } + + public function testFindEntityWithNullRelation(): void + { + $this->_em->getConnection()->insert('InvoiceClass', [ + 'companyCode' => 'BB', + 'invoiceNumber' => 'INV1', + ]); + + $entity = $this->findEntity('BB', 'INV1'); + self::assertSame('BB', $entity->companyCode); + self::assertSame('INV1', $entity->invoiceNumber); + self::assertNull($entity->customer); + } + + private function findEntity(string $companyCode, string $invoiceNumber): InvoiceClass + { + return $this->_em->find( + InvoiceClass::class, + ['companyCode' => $companyCode, 'invoiceNumber' => $invoiceNumber] + ); + } +} diff --git a/tests/Tests/OrmFunctionalTestCase.php b/tests/Tests/OrmFunctionalTestCase.php index 4a64136f184..f81eddc7d59 100644 --- a/tests/Tests/OrmFunctionalTestCase.php +++ b/tests/Tests/OrmFunctionalTestCase.php @@ -212,6 +212,10 @@ abstract class OrmFunctionalTestCase extends OrmTestCase Models\CompositeKeyInheritance\SingleRootClass::class, Models\CompositeKeyInheritance\SingleChildClass::class, ], + 'compositekeyrelations' => [ + Models\CompositeKeyRelations\InvoiceClass::class, + Models\CompositeKeyRelations\CustomerClass::class, + ], 'taxi' => [ Models\Taxi\PaidRide::class, Models\Taxi\Ride::class, From e3d7c6076c3975eedd1abb8f2b2eeb34efcd7b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Jun 2024 17:45:09 +0200 Subject: [PATCH 09/31] Use modern array syntax in the doc --- docs/en/tutorials/getting-started.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst index 688030cc149..1fa463dd797 100644 --- a/docs/en/tutorials/getting-started.rst +++ b/docs/en/tutorials/getting-started.rst @@ -144,7 +144,7 @@ step: // Create a simple "default" Doctrine ORM configuration for Attributes $config = ORMSetup::createAttributeMetadataConfiguration( - paths: array(__DIR__."/src"), + paths: [__DIR__ . '/src'], isDevMode: true, ); // or if you prefer annotation, YAML or XML @@ -153,7 +153,7 @@ step: // isDevMode: true, // ); // $config = ORMSetup::createXMLMetadataConfiguration( - // paths: array(__DIR__."/config/xml"), + // paths: [__DIR__ . '/config/xml'], // isDevMode: true, //); // $config = ORMSetup::createYAMLMetadataConfiguration( From 19129e9f8a012c0789d17ef72c263ea384bbc48b Mon Sep 17 00:00:00 2001 From: Konrad Abicht Date: Fri, 28 Jun 2024 08:02:28 +0200 Subject: [PATCH 10/31] working-with-objects.rst: added missing white space --- docs/en/reference/working-with-objects.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/en/reference/working-with-objects.rst b/docs/en/reference/working-with-objects.rst index d88b814e8c4..cc889ddde3f 100644 --- a/docs/en/reference/working-with-objects.rst +++ b/docs/en/reference/working-with-objects.rst @@ -338,10 +338,11 @@ Performance of different deletion strategies Deleting an object with all its associated objects can be achieved in multiple ways with very different performance impacts. -1. If an association is marked as ``CASCADE=REMOVE`` Doctrine ORM - will fetch this association. If its a Single association it will - pass this entity to - ``EntityManager#remove()``. If the association is a collection, Doctrine will loop over all its elements and pass them to``EntityManager#remove()``. +1. If an association is marked as ``CASCADE=REMOVE`` Doctrine ORM will + fetch this association. If it's a Single association it will pass + this entity to ``EntityManager#remove()``. If the association is a + collection, Doctrine will loop over all its elements and pass them to + ``EntityManager#remove()``. In both cases the cascade remove semantics are applied recursively. For large object graphs this removal strategy can be very costly. 2. Using a DQL ``DELETE`` statement allows you to delete multiple From 1fe1a6a048dd420d06704f72b296a463237d7603 Mon Sep 17 00:00:00 2001 From: Xesau Date: Mon, 1 Jul 2024 21:57:36 +0200 Subject: [PATCH 11/31] Fix incorrect exception message for ManyToOne attribute in embeddable class (#11536) When a ManyToOne attribute is encountered on an Embeddable class, the exception message reads "Attribute "Doctrine\ORM\Mapping\OneToMany" on embeddable [class] is not allowed.". This should be "Doctrine\ORM\Mapping\ManyToOne" on embeddable [class] is not allowed.". --- src/Mapping/Driver/AttributeDriver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mapping/Driver/AttributeDriver.php b/src/Mapping/Driver/AttributeDriver.php index 6fed1a24e67..9ba3481e3ba 100644 --- a/src/Mapping/Driver/AttributeDriver.php +++ b/src/Mapping/Driver/AttributeDriver.php @@ -390,7 +390,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $metadata->mapOneToMany($mapping); } elseif ($manyToOneAttribute !== null) { if ($metadata->isEmbeddedClass) { - throw MappingException::invalidAttributeOnEmbeddable($metadata->name, Mapping\OneToMany::class); + throw MappingException::invalidAttributeOnEmbeddable($metadata->name, Mapping\ManyToOne::class); } $idAttribute = $this->reader->getPropertyAttribute($property, Mapping\Id::class); From 9bd51aaeb6d0f61f4d25ea838a951cb52db1e8b7 Mon Sep 17 00:00:00 2001 From: Christophe Coevoet Date: Wed, 3 Jul 2024 15:14:49 +0200 Subject: [PATCH 12/31] Fix the support for custom parameter types in native queries The Query class (used for DQL queries) takes care of using the value and type as is when a type was specified for a parameter instead of going through the default processing of values. The NativeQuery class was missing the equivalent check, making the custom type work only if the default processing of values does not convert the value to a different one. --- src/NativeQuery.php | 10 +++++- tests/Tests/ORM/Query/NativeQueryTest.php | 42 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/Tests/ORM/Query/NativeQueryTest.php diff --git a/src/NativeQuery.php b/src/NativeQuery.php index aa44539d544..782983d50ec 100644 --- a/src/NativeQuery.php +++ b/src/NativeQuery.php @@ -50,7 +50,15 @@ protected function _doExecute() $types = []; foreach ($this->getParameters() as $parameter) { - $name = $parameter->getName(); + $name = $parameter->getName(); + + if ($parameter->typeWasSpecified()) { + $parameters[$name] = $parameter->getValue(); + $types[$name] = $parameter->getType(); + + continue; + } + $value = $this->processParameterValue($parameter->getValue()); $type = $parameter->getValue() === $value ? $parameter->getType() diff --git a/tests/Tests/ORM/Query/NativeQueryTest.php b/tests/Tests/ORM/Query/NativeQueryTest.php new file mode 100644 index 00000000000..0e68389494e --- /dev/null +++ b/tests/Tests/ORM/Query/NativeQueryTest.php @@ -0,0 +1,42 @@ +entityManager = $this->getTestEntityManager(); + } + + public function testValuesAreNotBeingResolvedForSpecifiedParameterTypes(): void + { + $unitOfWork = $this->createMock(UnitOfWork::class); + + $this->entityManager->setUnitOfWork($unitOfWork); + + $unitOfWork + ->expects(self::never()) + ->method('getSingleIdentifierValue'); + + $rsm = new ResultSetMapping(); + + $query = $this->entityManager->createNativeQuery('SELECT d.* FROM date_time_model d WHERE d.datetime = :value', $rsm); + + $query->setParameter('value', new DateTime(), Types::DATETIME_MUTABLE); + + self::assertEmpty($query->getResult()); + } +} From 0983d3a4af20124cc9f884727c555be34efdf34b Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 27 Jun 2024 09:48:45 +0300 Subject: [PATCH 13/31] Add `createNamedParameter` to `QueryBuilder` --- docs/en/reference/query-builder.rst | 18 ++++++++++++ src/QueryBuilder.php | 42 ++++++++++++++++++++++++++++ tests/Tests/ORM/QueryBuilderTest.php | 41 +++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/docs/en/reference/query-builder.rst b/docs/en/reference/query-builder.rst index 3070cc234df..41ef31420ae 100644 --- a/docs/en/reference/query-builder.rst +++ b/docs/en/reference/query-builder.rst @@ -611,3 +611,21 @@ same query of example 6 written using ->add('from', new Expr\From('User', 'u')) ->add('where', new Expr\Comparison('u.id', '=', '?1')) ->add('orderBy', new Expr\OrderBy('u.name', 'ASC')); + +Binding Parameters to Placeholders +---------------------------------- + +It is often not necessary to know about the exact placeholder names when +building a query. You can use a helper method to bind a value to a placeholder +and directly use that placeholder in your query as a return value: + +.. code-block:: php + + select('u') + ->from('User', 'u') + ->where('u.email = ' . $qb->createNamedParameter($userInputEmail)) + ; + // SELECT u FROM User u WHERE email = :dcValue1 diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index a6a39a964b8..fe2d750e338 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -110,6 +110,13 @@ class QueryBuilder implements Stringable protected int $lifetime = 0; + /** + * The counter of bound parameters. + * + * @var int<0, max> + */ + private int $boundCounter = 0; + /** * Initializes a new QueryBuilder that uses the given EntityManager. * @@ -1336,6 +1343,41 @@ public function resetDQLPart(string $part): static return $this; } + /** + * Creates a new named parameter and bind the value $value to it. + * + * The parameter $value specifies the value that you want to bind. If + * $placeholder is not provided createNamedParameter() will automatically + * create a placeholder for you. An automatic placeholder will be of the + * name ':dcValue1', ':dcValue2' etc. + * + * Example: + * + * $qb = $em->createQueryBuilder(); + * $qb + * ->select('u') + * ->from('User', 'u') + * ->where('u.username = ' . $qb->createNamedParameter('Foo', Types::STRING)) + * ->orWhere('u.username = ' . $qb->createNamedParameter('Bar', Types::STRING)) + * + * + * @param ParameterType|ArrayParameterType|string|int|null $type ParameterType::*, ArrayParameterType::* or \Doctrine\DBAL\Types\Type::* constant + * @param non-empty-string|null $placeholder The name to bind with. The string must start with a colon ':'. + * + * @return non-empty-string the placeholder name used. + */ + public function createNamedParameter(mixed $value, ParameterType|ArrayParameterType|string|int|null $type = null, string|null $placeholder = null): string + { + if ($placeholder === null) { + $this->boundCounter++; + $placeholder = ':dcValue' . $this->boundCounter; + } + + $this->setParameter(substr($placeholder, 1), $value, $type); + + return $placeholder; + } + /** * Gets a string representation of this QueryBuilder which corresponds to * the final DQL query being constructed. diff --git a/tests/Tests/ORM/QueryBuilderTest.php b/tests/Tests/ORM/QueryBuilderTest.php index 577c86581bf..fd610bced44 100644 --- a/tests/Tests/ORM/QueryBuilderTest.php +++ b/tests/Tests/ORM/QueryBuilderTest.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Order; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Cache; use Doctrine\ORM\Query; use Doctrine\ORM\Query\Expr\Join; @@ -1285,4 +1286,44 @@ public function testDeleteWithoutAlias(): void $this->expectExceptionMessage('Doctrine\ORM\QueryBuilder::delete(): The alias for entity Doctrine\Tests\Models\CMS\CmsUser u must not be omitted.'); $qb->delete(CmsUser::class . ' u'); } + + public function testCreateNamedParameter(): void + { + $qb = $this->entityManager->createQueryBuilder(); + + $qb->select('u') + ->from(CmsUser::class, 'u') + ->where( + $qb->expr()->eq('u.name', $qb->createNamedParameter('john doe', Types::STRING)), + ) + ->orWhere( + $qb->expr()->eq('u.rank', $qb->createNamedParameter(100, Types::INTEGER)), + ); + + self::assertEquals('SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.name = :dcValue1 OR u.rank = :dcValue2', $qb->getDQL()); + self::assertEquals('john doe', $qb->getParameter('dcValue1')->getValue()); + self::assertEquals(Types::STRING, $qb->getParameter('dcValue1')->getType()); + self::assertEquals(100, $qb->getParameter('dcValue2')->getValue()); + self::assertEquals(Types::INTEGER, $qb->getParameter('dcValue2')->getType()); + } + + public function testCreateNamedParameterCustomPlaceholder(): void + { + $qb = $this->entityManager->createQueryBuilder(); + + $qb->select('u') + ->from(CmsUser::class, 'u') + ->where( + $qb->expr()->eq('u.name', $qb->createNamedParameter('john doe', Types::STRING, ':test')), + ) + ->andWhere( + $qb->expr()->eq('u.rank', $qb->createNamedParameter(100, Types::INTEGER)), + ); + + self::assertEquals('SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.name = :test AND u.rank = :dcValue1', $qb->getDQL()); + self::assertEquals('john doe', $qb->getParameter('test')->getValue()); + self::assertEquals(Types::STRING, $qb->getParameter('test')->getType()); + self::assertEquals(100, $qb->getParameter('dcValue1')->getValue()); + self::assertEquals(Types::INTEGER, $qb->getParameter('dcValue1')->getType()); + } } From 57247ed6ca1a61d19f26912be3ab1e19528996f2 Mon Sep 17 00:00:00 2001 From: d-ph Date: Mon, 22 Jul 2024 10:06:18 +0100 Subject: [PATCH 14/31] Make CountWalker use COUNT(*) when $distinct is explicitly set to false (#11552) This change makes CountWalker use COUNT(*) instead of COUNT(tbl.id), when the user declared that their query does not need to use (SELECT) DISTINCT, which is commonly the case when there are no JOINs in the query, or when the JOINs are only *ToOne. Research showed that COUNT(*) allows databases to use index(-only) scans more eagerly from any of the indexed columns, especially when the query is using a WHERE-condition that filters on an indexed column. --- src/Tools/Pagination/CountWalker.php | 36 ++++++++++--------- .../ORM/Tools/Pagination/CountWalkerTest.php | 14 ++++++++ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/Tools/Pagination/CountWalker.php b/src/Tools/Pagination/CountWalker.php index d2129435558..f11b25d6821 100644 --- a/src/Tools/Pagination/CountWalker.php +++ b/src/Tools/Pagination/CountWalker.php @@ -37,27 +37,31 @@ public function walkSelectStatement(SelectStatement $selectStatement): void throw new RuntimeException('Cannot count query which selects two FROM components, cannot make distinction'); } - $fromRoot = reset($from); - $rootAlias = $fromRoot->rangeVariableDeclaration->aliasIdentificationVariable; - $rootClass = $this->getMetadataForDqlAlias($rootAlias); - $identifierFieldName = $rootClass->getSingleIdentifierFieldName(); + $distinct = $this->_getQuery()->getHint(self::HINT_DISTINCT); - $pathType = PathExpression::TYPE_STATE_FIELD; - if (isset($rootClass->associationMappings[$identifierFieldName])) { - $pathType = PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION; - } + $countPathExpressionOrLiteral = '*'; + if ($distinct) { + $fromRoot = reset($from); + $rootAlias = $fromRoot->rangeVariableDeclaration->aliasIdentificationVariable; + $rootClass = $this->getMetadataForDqlAlias($rootAlias); + $identifierFieldName = $rootClass->getSingleIdentifierFieldName(); + + $pathType = PathExpression::TYPE_STATE_FIELD; + if (isset($rootClass->associationMappings[$identifierFieldName])) { + $pathType = PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION; + } - $pathExpression = new PathExpression( - PathExpression::TYPE_STATE_FIELD | PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION, - $rootAlias, - $identifierFieldName, - ); - $pathExpression->type = $pathType; + $countPathExpressionOrLiteral = new PathExpression( + PathExpression::TYPE_STATE_FIELD | PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION, + $rootAlias, + $identifierFieldName, + ); + $countPathExpressionOrLiteral->type = $pathType; + } - $distinct = $this->_getQuery()->getHint(self::HINT_DISTINCT); $selectStatement->selectClause->selectExpressions = [ new SelectExpression( - new AggregateExpression('count', $pathExpression, $distinct), + new AggregateExpression('count', $countPathExpressionOrLiteral, $distinct), null, ), ]; diff --git a/tests/Tests/ORM/Tools/Pagination/CountWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/CountWalkerTest.php index 5af90e87949..6a223e67f50 100644 --- a/tests/Tests/ORM/Tools/Pagination/CountWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/CountWalkerTest.php @@ -27,6 +27,20 @@ public function testCountQuery(): void ); } + public function testCountQueryWithoutDistinctUsesCountStar(): void + { + $query = $this->entityManager->createQuery( + 'SELECT p, c, a FROM Doctrine\Tests\ORM\Tools\Pagination\BlogPost p JOIN p.category c JOIN p.author a', + ); + $query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CountWalker::class]); + $query->setHint(CountWalker::HINT_DISTINCT, false); + + self::assertEquals( + 'SELECT count(*) AS sclr_0 FROM BlogPost b0_ INNER JOIN Category c1_ ON b0_.category_id = c1_.id INNER JOIN Author a2_ ON b0_.author_id = a2_.id', + $query->getSQL(), + ); + } + public function testCountQueryMixedResultsWithName(): void { $query = $this->entityManager->createQuery( From 56cd688c4a74ba5dfa732bfdc5baa159ccb92328 Mon Sep 17 00:00:00 2001 From: Marc Eichenseher Date: Fri, 2 Aug 2024 01:01:23 +0200 Subject: [PATCH 15/31] Remove unused $pkColumns when gathering columns (#11560) --- src/Tools/SchemaTool.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Tools/SchemaTool.php b/src/Tools/SchemaTool.php index 42b52df8c71..cff59aecdd8 100644 --- a/src/Tools/SchemaTool.php +++ b/src/Tools/SchemaTool.php @@ -421,18 +421,12 @@ private function addDiscriminatorColumnDefinition(ClassMetadata $class, Table $t */ private function gatherColumns(ClassMetadata $class, Table $table): void { - $pkColumns = []; - foreach ($class->fieldMappings as $mapping) { if ($class->isInheritanceTypeSingleTable() && isset($mapping->inherited)) { continue; } $this->gatherColumn($class, $mapping, $table); - - if ($class->isIdentifier($mapping->fieldName)) { - $pkColumns[] = $this->quoteStrategy->getColumnName($mapping->fieldName, $class, $this->platform); - } } } From 121158f92c9e5c5ed52accafeaaca8e3412cf9ec Mon Sep 17 00:00:00 2001 From: Kyron Taylor Date: Sat, 3 Aug 2024 16:49:18 +0100 Subject: [PATCH 16/31] GH11551 - fix OneToManyPersister::deleteEntityCollection when using single-inheritence entity parent as targetEntity. When using the parent entity for a single-inheritence table as the targetEntity for a property, the discriminator value should be all of the values in the discriminator map. OneToManyPersister::deleteEntityCollection has been amended to reflect this. --- .../Collection/OneToManyPersister.php | 12 +- .../ORM/Functional/Ticket/GH11501Test.php | 120 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/Tests/ORM/Functional/Ticket/GH11501Test.php diff --git a/src/Persisters/Collection/OneToManyPersister.php b/src/Persisters/Collection/OneToManyPersister.php index 6769acca909..aed37556bc7 100644 --- a/src/Persisters/Collection/OneToManyPersister.php +++ b/src/Persisters/Collection/OneToManyPersister.php @@ -13,10 +13,13 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Utility\PersisterHelper; +use function array_fill; +use function array_keys; use function array_merge; use function array_reverse; use function array_values; use function assert; +use function count; use function implode; use function is_int; use function is_string; @@ -194,9 +197,12 @@ private function deleteEntityCollection(PersistentCollection $collection): int if ($targetClass->isInheritanceTypeSingleTable()) { $discriminatorColumn = $targetClass->getDiscriminatorColumn(); - $statement .= ' AND ' . $discriminatorColumn['name'] . ' = ?'; - $parameters[] = $targetClass->discriminatorValue; - $types[] = $discriminatorColumn['type']; + $discriminatorValues = $targetClass->discriminatorValue ? [$targetClass->discriminatorValue] : array_keys($targetClass->discriminatorMap); + $statement .= ' AND ' . $discriminatorColumn['name'] . ' IN (' . implode(', ', array_fill(0, count($discriminatorValues), '?')) . ')'; + foreach ($discriminatorValues as $discriminatorValue) { + $parameters[] = $discriminatorValue; + $types[] = $discriminatorColumn['type']; + } } $numAffected = $this->conn->executeStatement($statement, $parameters, $types); diff --git a/tests/Tests/ORM/Functional/Ticket/GH11501Test.php b/tests/Tests/ORM/Functional/Ticket/GH11501Test.php new file mode 100644 index 00000000000..715137d43af --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11501Test.php @@ -0,0 +1,120 @@ +setUpEntitySchema([ + GH11501AbstractTestEntity::class, + GH11501TestEntityOne::class, + GH11501TestEntityTwo::class, + GH11501TestEntityHolder::class, + ]); + } + + /** + * @throws ORMException + */ + public function testDeleteOneToManyCollectionWithSingleTableInheritance(): void + { + $testEntityOne = new GH11501TestEntityOne(); + $testEntityTwo = new GH11501TestEntityTwo(); + $testEntityHolder = new GH11501TestEntityHolder(); + + $testEntityOne->testEntityHolder = $testEntityHolder; + $testEntityHolder->testEntities->add($testEntityOne); + + $testEntityTwo->testEntityHolder = $testEntityHolder; + $testEntityHolder->testEntities->add($testEntityTwo); + + $em = $this->getEntityManager(); + $em->persist($testEntityOne); + $em->persist($testEntityTwo); + $em->persist($testEntityHolder); + $em->flush(); + + $testEntityHolder->testEntities = new ArrayCollection(); + $em->persist($testEntityHolder); + $em->flush(); + $em->refresh($testEntityHolder); + + static::assertEmpty($testEntityHolder->testEntities->toArray(), 'All records should have been deleted'); + } +} + + + +/** + * @ORM\Entity + * @ORM\Table(name="one_to_many_single_table_inheritance_test_entities_parent_join") + * @ORM\InheritanceType("SINGLE_TABLE") + * @ORM\DiscriminatorColumn(name="type", type="string") + * @ORM\DiscriminatorMap({"test_entity_one"="GH11501TestEntityOne", "test_entity_two"="GH11501TestEntityTwo"}) + */ +class GH11501AbstractTestEntity +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH11501TestEntityHolder", inversedBy="testEntities") + * @ORM\JoinColumn(name="test_entity_holder_id", referencedColumnName="id") + * + * @var GH11501TestEntityHolder + */ + public $testEntityHolder; +} + + +/** @ORM\Entity */ +class GH11501TestEntityOne extends GH11501AbstractTestEntity +{ +} + +/** @ORM\Entity */ +class GH11501TestEntityTwo extends GH11501AbstractTestEntity +{ +} + +/** @ORM\Entity */ +class GH11501TestEntityHolder +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\OneToMany(targetEntity="GH11501AbstractTestEntity", mappedBy="testEntityHolder", orphanRemoval=true) + * + * @var Collection + */ + public $testEntities; + + public function __construct() + { + $this->testEntities = new ArrayCollection(); + } +} From 2707b09a07e00097bfef81f3ee35c6435586e0a6 Mon Sep 17 00:00:00 2001 From: gitbugr Date: Sat, 3 Aug 2024 21:38:49 +0100 Subject: [PATCH 17/31] fix spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- src/Persisters/Collection/OneToManyPersister.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Persisters/Collection/OneToManyPersister.php b/src/Persisters/Collection/OneToManyPersister.php index aed37556bc7..1e032e99b49 100644 --- a/src/Persisters/Collection/OneToManyPersister.php +++ b/src/Persisters/Collection/OneToManyPersister.php @@ -201,7 +201,7 @@ private function deleteEntityCollection(PersistentCollection $collection): int $statement .= ' AND ' . $discriminatorColumn['name'] . ' IN (' . implode(', ', array_fill(0, count($discriminatorValues), '?')) . ')'; foreach ($discriminatorValues as $discriminatorValue) { $parameters[] = $discriminatorValue; - $types[] = $discriminatorColumn['type']; + $types[] = $discriminatorColumn['type']; } } From 3f550c19e3cbdb5d5a13be10182eb3d52d69b604 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 11 Jul 2024 14:41:28 +0200 Subject: [PATCH 18/31] DQL custom functions: document TypedExpression Partially related to https://github.com/doctrine/orm/issues/11537 Co-authored-by: Claudio Zizza <859964+SenseException@users.noreply.github.com> --- .../cookbook/dql-user-defined-functions.rst | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/en/cookbook/dql-user-defined-functions.rst b/docs/en/cookbook/dql-user-defined-functions.rst index b189ed59fcd..e1782e05669 100644 --- a/docs/en/cookbook/dql-user-defined-functions.rst +++ b/docs/en/cookbook/dql-user-defined-functions.rst @@ -232,6 +232,33 @@ vendors SQL parser to show us further errors in the parsing process, for example if the Unit would not be one of the supported values by MySql. +Typed functions +--------------- +By default, result of custom functions is fetched as-is from the database driver. +If you want to be sure that the type is always the same, then your custom function needs to +implement ``Doctrine\ORM\Query\AST\TypedExpression``. Then, the result is wired +through ``Doctrine\DBAL\Types\Type::convertToPhpValue()`` of the ``Type`` returned in ``getReturnType()``. + +.. code-block:: php + + Date: Fri, 16 Aug 2024 18:49:21 -0600 Subject: [PATCH 19/31] Original entity data resolves inverse 1-1 joins If the source entity for an inverse (non-owning) 1-1 relationship is identified by an association then the identifying association may not be set when an inverse one-to-one association is resolved. This means that no data is available in the entity to resolve the needed column value for the join query. The original entity data can be retrieved from the unit of work and is used as a fallback to populate the query condition. Fixes #11108 --- .../Entity/BasicEntityPersister.php | 39 +++++++++-- .../InverseSide.php | 34 ++++++++++ .../InverseSideIdTarget.php | 33 +++++++++ .../OwningSide.php | 37 ++++++++++ ...WithAssociativeIdLoadAfterDqlQueryTest.php | 68 +++++++++++++++++++ 5 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php create mode 100644 tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSideIdTarget.php create mode 100644 tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/OwningSide.php create mode 100644 tests/Tests/ORM/Functional/OneToOneInverseSideWithAssociativeIdLoadAfterDqlQueryTest.php diff --git a/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index 00fe7b03703..5ca00cb007e 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -832,17 +832,42 @@ public function loadOneToOneEntity(array $assoc, $sourceEntity, array $identifie $computedIdentifier = []; + /** @var array|null $sourceEntityData */ + $sourceEntityData = null; + // TRICKY: since the association is specular source and target are flipped foreach ($owningAssoc['targetToSourceKeyColumns'] as $sourceKeyColumn => $targetKeyColumn) { if (! isset($sourceClass->fieldNames[$sourceKeyColumn])) { - throw MappingException::joinColumnMustPointToMappedField( - $sourceClass->name, - $sourceKeyColumn - ); - } + // The likely case here is that the column is a join column + // in an association mapping. However, there is no guarantee + // at this point that a corresponding (generally identifying) + // association has been mapped in the source entity. To handle + // this case we directly reference the column-keyed data used + // to initialize the source entity before throwing an exception. + $resolvedSourceData = false; + if (! isset($sourceEntityData)) { + $sourceEntityData = $this->em->getUnitOfWork()->getOriginalEntityData($sourceEntity); + } + + if (isset($sourceEntityData[$sourceKeyColumn])) { + $dataValue = $sourceEntityData[$sourceKeyColumn]; + if ($dataValue !== null) { + $resolvedSourceData = true; + $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = + $dataValue; + } + } - $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = - $sourceClass->reflFields[$sourceClass->fieldNames[$sourceKeyColumn]]->getValue($sourceEntity); + if (! $resolvedSourceData) { + throw MappingException::joinColumnMustPointToMappedField( + $sourceClass->name, + $sourceKeyColumn + ); + } + } else { + $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = + $sourceClass->reflFields[$sourceClass->fieldNames[$sourceKeyColumn]]->getValue($sourceEntity); + } } $targetEntity = $this->load($computedIdentifier, null, $assoc); diff --git a/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php b/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php new file mode 100644 index 00000000000..0dcb9a93a1b --- /dev/null +++ b/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php @@ -0,0 +1,34 @@ +createSchemaForModels(OwningSide::class, InverseSideIdTarget::class, InverseSide::class); + } + + /** @group GH-11108 */ + public function testInverseSideWithAssociativeIdOneToOneLoadedAfterDqlQuery(): void + { + $owner = new OwningSide(); + $inverseId = new InverseSideIdTarget(); + $inverse = new InverseSide(); + + $owner->id = 'owner'; + $inverseId->id = 'inverseId'; + $inverseId->inverseSide = $inverse; + $inverse->associativeId = $inverseId; + $owner->inverse = $inverse; + $inverse->owning = $owner; + + $this->_em->persist($owner); + $this->_em->persist($inverseId); + $this->_em->persist($inverse); + $this->_em->flush(); + $this->_em->clear(); + + $fetchedInverse = $this + ->_em + ->createQueryBuilder() + ->select('inverse') + ->from(InverseSide::class, 'inverse') + ->andWhere('inverse.associativeId = :associativeId') + ->setParameter('associativeId', 'inverseId') + ->getQuery() + ->getSingleResult(); + assert($fetchedInverse instanceof InverseSide); + + self::assertInstanceOf(InverseSide::class, $fetchedInverse); + self::assertInstanceOf(InverseSideIdTarget::class, $fetchedInverse->associativeId); + self::assertInstanceOf(OwningSide::class, $fetchedInverse->owning); + + $this->assertSQLEquals( + 'select o0_.associativeid as associativeid_0 from one_to_one_inverse_side_assoc_id_load_inverse o0_ where o0_.associativeid = ?', + $this->getLastLoggedQuery(1)['sql'] + ); + + $this->assertSQLEquals( + 'select t0.id as id_1, t0.inverse as inverse_2 from one_to_one_inverse_side_assoc_id_load_owning t0 where t0.inverse = ?', + $this->getLastLoggedQuery()['sql'] + ); + } +} From 8c582a49d375e6909073820f2ca37ba5d06c2d7e Mon Sep 17 00:00:00 2001 From: eltharin Date: Mon, 12 Aug 2024 09:12:53 +0200 Subject: [PATCH 20/31] Add support for using nested DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This feature allow use of nested new operators Co-authored-by: Tomas Norkūnas Co-authored-by: Sergey Protko Co-authored-by: Łukasz Zakrzewski Update docs/en/reference/dql-doctrine-query-language.rst Co-authored-by: Claudio Zizza <859964+SenseException@users.noreply.github.com> --- .../reference/dql-doctrine-query-language.rst | 29 ++++- psalm-baseline.xml | 10 +- src/Internal/Hydration/AbstractHydrator.php | 34 +++++- src/Internal/Hydration/ArrayHydrator.php | 5 +- src/Internal/Hydration/ObjectHydrator.php | 4 +- src/Query/Parser.php | 4 + src/Query/ResultSetMapping.php | 29 +++++ src/Query/SqlWalker.php | 24 +++- tests/Tests/Models/CMS/CmsAddressDTO.php | 2 +- tests/Tests/Models/CMS/CmsUserDTO.php | 2 +- .../Tests/Models/DDC6573/DDC6573Currency.php | 17 +++ tests/Tests/Models/DDC6573/DDC6573Item.php | 44 +++++++ tests/Tests/Models/DDC6573/DDC6573Money.php | 24 ++++ .../Tests/ORM/Functional/NewOperatorTest.php | 67 +++++++++++ .../ORM/Functional/Ticket/DDC6573Test.php | 108 ++++++++++++++++++ .../ORM/Hydration/ResultSetMappingTest.php | 17 +++ 16 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 tests/Tests/Models/DDC6573/DDC6573Currency.php create mode 100644 tests/Tests/Models/DDC6573/DDC6573Item.php create mode 100644 tests/Tests/Models/DDC6573/DDC6573Money.php create mode 100644 tests/Tests/ORM/Functional/Ticket/DDC6573Test.php diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 12b08823811..c2b31cd326d 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -579,7 +579,34 @@ And then use the ``NEW`` DQL keyword : $query = $em->createQuery('SELECT NEW CustomerDTO(c.name, e.email, a.city, SUM(o.value)) FROM Customer c JOIN c.email e JOIN c.address a JOIN c.orders o GROUP BY c'); $users = $query->getResult(); // array of CustomerDTO -Note that you can only pass scalar expressions to the constructor. +You can also nest several DTO : + +.. 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. Using INDEX BY ~~~~~~~~~~~~~~ diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 7a84afd4f83..b83ae43a889 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -216,6 +216,10 @@ + + + + @@ -228,9 +232,6 @@ - - - @@ -265,9 +266,6 @@ - - - diff --git a/src/Internal/Hydration/AbstractHydrator.php b/src/Internal/Hydration/AbstractHydrator.php index d8bffe4ad39..c8186171466 100644 --- a/src/Internal/Hydration/AbstractHydrator.php +++ b/src/Internal/Hydration/AbstractHydrator.php @@ -252,8 +252,9 @@ abstract protected function hydrateAllData(): mixed; * @psalm-return array{ * data: array, * newObjects?: array, * scalars?: array * } @@ -281,6 +282,10 @@ 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; @@ -335,6 +340,31 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon } } + foreach ($this->resultSetMapping()->nestedNewObjectArguments as $objIndex => ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex]) { + if (! isset($rowData['newObjects'][$objIndex])) { + continue; + } + + $newObject = $rowData['newObjects'][$objIndex]; + unset($rowData['newObjects'][$objIndex]); + + $class = $newObject['class']; + $args = $newObject['args']; + $obj = $class->newInstanceArgs($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); + + $rowData['newObjects'][$objIndex]['obj'] = $obj; + } + } + return $rowData; } diff --git a/src/Internal/Hydration/ArrayHydrator.php b/src/Internal/Hydration/ArrayHydrator.php index 7115c16c47b..576b89174d3 100644 --- a/src/Internal/Hydration/ArrayHydrator.php +++ b/src/Internal/Hydration/ArrayHydrator.php @@ -214,9 +214,8 @@ protected function hydrateRowData(array $row, array &$result): void $scalarCount = (isset($rowData['scalars']) ? count($rowData['scalars']) : 0); foreach ($rowData['newObjects'] as $objIndex => $newObject) { - $class = $newObject['class']; - $args = $newObject['args']; - $obj = $class->newInstanceArgs($args); + $args = $newObject['args']; + $obj = $newObject['obj']; if (count($args) === $scalarCount || ($scalarCount === 0 && count($rowData['newObjects']) === 1)) { $result[$resultKey] = $obj; diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index d0fc101f215..f151fb813ca 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -556,9 +556,7 @@ protected function hydrateRowData(array $row, array &$result): void $scalarCount = (isset($rowData['scalars']) ? count($rowData['scalars']) : 0); foreach ($rowData['newObjects'] as $objIndex => $newObject) { - $class = $newObject['class']; - $args = $newObject['args']; - $obj = $class->newInstanceArgs($args); + $obj = $newObject['obj']; if ($scalarCount === 0 && count($rowData['newObjects']) === 1) { $result[$resultKey] = $obj; diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 42b0027f579..38858cbf876 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1782,6 +1782,10 @@ public function NewObjectArg(): mixed return $expression; } + if ($token->type === TokenType::T_NEW) { + return $this->NewObjectExpression(); + } + return $this->ScalarExpression(); } diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php index 612474db1d2..c95b089a73b 100644 --- a/src/Query/ResultSetMapping.php +++ b/src/Query/ResultSetMapping.php @@ -4,6 +4,7 @@ namespace Doctrine\ORM\Query; +use function array_merge; use function count; /** @@ -152,6 +153,13 @@ class ResultSetMapping */ public array $newObjectMappings = []; + /** + * Maps last argument for new objects in order to initiate object construction + * + * @psalm-var array + */ + public array $nestedNewObjectArguments = []; + /** * Maps metadata parameter names to the metadata attribute. * @@ -544,4 +552,25 @@ public function addMetaResult( return $this; } + + public function addNewObjectAsArgument(string|int $alias, string|int $objOwner, int $objOwnerIdx): static + { + $owner = [ + 'ownerIndex' => $objOwner, + 'argIndex' => $objOwnerIdx, + ]; + + if (! isset($this->nestedNewObjectArguments[$owner['ownerIndex']])) { + $this->nestedNewObjectArguments[$alias] = $owner; + + return $this; + } + + $this->nestedNewObjectArguments = array_merge( + [$alias => $owner], + $this->nestedNewObjectArguments, + ); + + return $this; + } } diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 7f9bb110cac..46296e719e7 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -25,8 +25,10 @@ use function array_keys; use function array_map; use function array_merge; +use function array_pop; use function assert; use function count; +use function end; use function implode; use function in_array; use function is_array; @@ -85,6 +87,13 @@ class SqlWalker */ private int $newObjectCounter = 0; + /** + * Contains nesting levels of new objects arguments + * + * @psalm-var array + */ + private array $newObjectStack = []; + private readonly EntityManagerInterface $em; private readonly Connection $conn; @@ -1482,7 +1491,14 @@ public function walkParenthesisExpression(AST\ParenthesisExpression $parenthesis public function walkNewObject(AST\NewObjectExpression $newObjectExpression, string|null $newObjectResultAlias = null): string { $sqlSelectExpressions = []; - $objIndex = $newObjectResultAlias ?: $this->newObjectCounter++; + $objOwner = $objOwnerIdx = null; + + if ($this->newObjectStack !== []) { + [$objOwner, $objOwnerIdx] = end($this->newObjectStack); + $objIndex = $objOwner . ':' . $objOwnerIdx; + } else { + $objIndex = $newObjectResultAlias ?: $this->newObjectCounter++; + } foreach ($newObjectExpression->args as $argIndex => $e) { $resultAlias = $this->scalarResultCounter++; @@ -1491,7 +1507,9 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri switch (true) { case $e instanceof AST\NewObjectExpression: + $this->newObjectStack[] = [$objIndex, $argIndex]; $sqlSelectExpressions[] = $e->dispatch($this); + array_pop($this->newObjectStack); break; case $e instanceof AST\Subselect: @@ -1545,6 +1563,10 @@ 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/tests/Tests/Models/CMS/CmsAddressDTO.php b/tests/Tests/Models/CMS/CmsAddressDTO.php index cfe1579aaf9..502644ed25e 100644 --- a/tests/Tests/Models/CMS/CmsAddressDTO.php +++ b/tests/Tests/Models/CMS/CmsAddressDTO.php @@ -6,7 +6,7 @@ class CmsAddressDTO { - public function __construct(public string|null $country = null, public string|null $city = null, public string|null $zip = null) + public function __construct(public string|null $country = null, public string|null $city = null, public string|null $zip = null, public CmsAddressDTO|string|null $address = null) { } } diff --git a/tests/Tests/Models/CMS/CmsUserDTO.php b/tests/Tests/Models/CMS/CmsUserDTO.php index 36b639aeb73..f2dc43114db 100644 --- a/tests/Tests/Models/CMS/CmsUserDTO.php +++ b/tests/Tests/Models/CMS/CmsUserDTO.php @@ -6,7 +6,7 @@ class CmsUserDTO { - public function __construct(public string|null $name = null, public string|null $email = null, public string|null $address = null, public int|null $phonenumbers = null) + public function __construct(public string|null $name = null, public string|null $email = null, public CmsAddressDTO|string|null $address = null, public int|null $phonenumbers = null) { } } diff --git a/tests/Tests/Models/DDC6573/DDC6573Currency.php b/tests/Tests/Models/DDC6573/DDC6573Currency.php new file mode 100644 index 00000000000..9aa5b0eb9e1 --- /dev/null +++ b/tests/Tests/Models/DDC6573/DDC6573Currency.php @@ -0,0 +1,17 @@ +code; + } +} diff --git a/tests/Tests/Models/DDC6573/DDC6573Item.php b/tests/Tests/Models/DDC6573/DDC6573Item.php new file mode 100644 index 00000000000..29b99a2d6fb --- /dev/null +++ b/tests/Tests/Models/DDC6573/DDC6573Item.php @@ -0,0 +1,44 @@ +name = $name; + $this->priceAmount = $price->getAmount(); + $this->priceCurrency = $price->getCurrency()->getCode(); + } + + public function getPrice(): DDC6573Money + { + return new DDC6573Money($this->priceAmount, new DDC6573Currency($this->priceCurrency)); + } +} diff --git a/tests/Tests/Models/DDC6573/DDC6573Money.php b/tests/Tests/Models/DDC6573/DDC6573Money.php new file mode 100644 index 00000000000..f0d0d59ea48 --- /dev/null +++ b/tests/Tests/Models/DDC6573/DDC6573Money.php @@ -0,0 +1,24 @@ +amount; + } + + public function getCurrency(): DDC6573Currency + { + return $this->currency; + } +} diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php index 7f89a938e88..4497af517bf 100644 --- a/tests/Tests/ORM/Functional/NewOperatorTest.php +++ b/tests/Tests/ORM/Functional/NewOperatorTest.php @@ -1013,6 +1013,73 @@ public function testClassCantBeInstantiatedException(): void $dql = 'SELECT new Doctrine\Tests\ORM\Functional\ClassWithPrivateConstructor(u.name) FROM Doctrine\Tests\Models\CMS\CmsUser u'; $this->_em->createQuery($dql)->getResult(); } + + public function testShouldSupportNestedNewOperators(): void + { + $dql = ' + SELECT + new CmsUserDTO( + u.name, + e.email, + new CmsAddressDTO( + a.country, + a.city, + a.zip, + new CmsAddressDTO( + a.country, + a.city, + a.zip + ) + ) + ) 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(CmsUserDTO::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTO::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTO::class, $result[2]['user']); + + self::assertInstanceOf(CmsAddressDTO::class, $result[0]['user']->address); + self::assertInstanceOf(CmsAddressDTO::class, $result[1]['user']->address); + self::assertInstanceOf(CmsAddressDTO::class, $result[2]['user']->address); + + 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']->address->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]['user']->address->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]['user']->address->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]['user']->address->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]['user']->address->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]['user']->address->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']); + } } class ClassWithTooMuchArgs diff --git a/tests/Tests/ORM/Functional/Ticket/DDC6573Test.php b/tests/Tests/ORM/Functional/Ticket/DDC6573Test.php new file mode 100644 index 00000000000..4802e596bfb --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/DDC6573Test.php @@ -0,0 +1,108 @@ + */ + private $fixtures; + + protected function setUp(): void + { + parent::setUp(); + + $this->createSchemaForModels( + DDC6573Item::class, + ); + + $item1 = new DDC6573Item('Plate', new DDC6573Money(5, new DDC6573Currency('GBP'))); + $item2 = new DDC6573Item('Iron', new DDC6573Money(50, new DDC6573Currency('EUR'))); + $item3 = new DDC6573Item('Teapot', new DDC6573Money(10, new DDC6573Currency('GBP'))); + + $this->_em->persist($item1); + $this->_em->persist($item2); + $this->_em->persist($item3); + + $this->_em->flush(); + $this->_em->clear(); + + $this->fixtures = [$item1, $item2, $item3]; + } + + protected function tearDown(): void + { + $this->_em->createQuery('DELETE FROM Doctrine\Tests\Models\DDC6573\DDC6573Item i')->execute(); + } + + public static function provideDataForHydrationMode(): iterable + { + yield [AbstractQuery::HYDRATE_ARRAY]; + yield [AbstractQuery::HYDRATE_OBJECT]; + } + + #[DataProvider('provideDataForHydrationMode')] + public function testShouldSupportsMultipleNewOperator(int $hydrationMode): void + { + $dql = ' + SELECT + new Doctrine\Tests\Models\DDC6573\DDC6573Money( + i.priceAmount, + new Doctrine\Tests\Models\DDC6573\DDC6573Currency(i.priceCurrency) + ) + FROM + Doctrine\Tests\Models\DDC6573\DDC6573Item i + ORDER BY + i.priceAmount ASC'; + + $query = $this->_em->createQuery($dql); + $result = $query->getResult($hydrationMode); + + self::assertCount(3, $result); + + self::assertInstanceOf(DDC6573Money::class, $result[0]); + self::assertInstanceOf(DDC6573Money::class, $result[1]); + self::assertInstanceOf(DDC6573Money::class, $result[2]); + + self::assertEquals($this->fixtures[0]->getPrice(), $result[0]); + self::assertEquals($this->fixtures[2]->getPrice(), $result[1]); + self::assertEquals($this->fixtures[1]->getPrice(), $result[2]); + } + + #[DataProvider('provideDataForHydrationMode')] + public function testShouldSupportsBasicUsage(int $hydrationMode): void + { + $dql = ' + SELECT + new Doctrine\Tests\Models\DDC6573\DDC6573Currency( + i.priceCurrency + ) + FROM + Doctrine\Tests\Models\DDC6573\DDC6573Item i + ORDER BY + i.priceAmount'; + + $query = $this->_em->createQuery($dql); + $result = $query->getResult($hydrationMode); + + self::assertCount(3, $result); + + self::assertInstanceOf(DDC6573Currency::class, $result[0]); + self::assertInstanceOf(DDC6573Currency::class, $result[1]); + self::assertInstanceOf(DDC6573Currency::class, $result[2]); + + self::assertEquals($this->fixtures[0]->getPrice()->getCurrency(), $result[0]); + self::assertEquals($this->fixtures[1]->getPrice()->getCurrency(), $result[2]); + self::assertEquals($this->fixtures[2]->getPrice()->getCurrency(), $result[1]); + } +} diff --git a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php index 0c20eab0866..14b9205abfe 100644 --- a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php +++ b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php @@ -102,4 +102,21 @@ public function testIndexByMetadataColumn(): void self::assertTrue($this->_rsm->hasIndexBy('lu')); } + + public function testNewObjectNestedArgumentsDeepestLeavesShouldComeFirst(): void + { + $this->_rsm->addNewObjectAsArgument('objALevel2', 'objALevel1', 0); + $this->_rsm->addNewObjectAsArgument('objALevel3', 'objALevel2', 1); + $this->_rsm->addNewObjectAsArgument('objBLevel3', 'objBLevel2', 0); + $this->_rsm->addNewObjectAsArgument('objBLevel2', 'objBLevel1', 1); + + $expectedArgumentMapping = [ + 'objALevel3' => ['ownerIndex' => 'objALevel2', 'argIndex' => 1], + 'objALevel2' => ['ownerIndex' => 'objALevel1', 'argIndex' => 0], + 'objBLevel3' => ['ownerIndex' => 'objBLevel2', 'argIndex' => 0], + 'objBLevel2' => ['ownerIndex' => 'objBLevel1', 'argIndex' => 1], + ]; + + self::assertSame($expectedArgumentMapping, $this->_rsm->nestedNewObjectArguments); + } } From c6b2d897489d535536de435bdef48f2783366ffb Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 24 Aug 2024 07:29:36 +0200 Subject: [PATCH 21/31] Precise EntityRepository::count (#11579) --- src/EntityRepository.php | 1 + src/Persisters/Entity/BasicEntityPersister.php | 5 ++++- src/Persisters/Entity/EntityPersister.php | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/EntityRepository.php b/src/EntityRepository.php index a53c5284881..ad472315398 100644 --- a/src/EntityRepository.php +++ b/src/EntityRepository.php @@ -131,6 +131,7 @@ public function findOneBy(array $criteria, array|null $orderBy = null): object|n * @psalm-param array $criteria * * @return int The cardinality of the objects that match the given criteria. + * @psalm-return 0|positive-int * * @todo Add this method to `ObjectRepository` interface in the next major release */ diff --git a/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index abaf8f4c87b..1bb54ed4410 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -860,7 +860,10 @@ public function count(array|Criteria $criteria = []): int ? $this->expandCriteriaParameters($criteria) : $this->expandParameters($criteria); - return (int) $this->conn->executeQuery($sql, $params, $types)->fetchOne(); + $count = (int) $this->conn->executeQuery($sql, $params, $types)->fetchOne(); + assert($count >= 0); + + return $count; } /** diff --git a/src/Persisters/Entity/EntityPersister.php b/src/Persisters/Entity/EntityPersister.php index 6b278a711d0..ad1c81147ce 100644 --- a/src/Persisters/Entity/EntityPersister.php +++ b/src/Persisters/Entity/EntityPersister.php @@ -125,6 +125,8 @@ public function delete(object $entity): bool; * Count entities (optionally filtered by a criteria) * * @param mixed[]|Criteria $criteria + * + * @psalm-return 0|positive-int */ public function count(array|Criteria $criteria = []): int; From 6cde337777a8d854095c013144913727651e605f Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 27 Aug 2024 12:10:07 +0200 Subject: [PATCH 22/31] PHPStan 1.12 (#11585) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ebf92996079..76df2056e4b 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "doctrine/annotations": "^1.13 || ^2", "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", - "phpstan/phpstan": "~1.4.10 || 1.11.1", + "phpstan/phpstan": "~1.4.10 || 1.12.0", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", From cfc0655a1c9b5ffd5506172cb7642b6090aa5f7e Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 3 Sep 2024 18:44:06 +0200 Subject: [PATCH 23/31] Fix compatibility with DBAL 4.2 (#11592) --- tests/Performance/ArrayResultFactory.php | 18 -------- tests/Performance/EntityManagerFactory.php | 4 +- ...etchJoinArrayHydrationPerformanceBench.php | 4 +- ...oinFullObjectHydrationPerformanceBench.php | 4 +- ...pleQueryArrayHydrationPerformanceBench.php | 4 +- ...eryFullObjectHydrationPerformanceBench.php | 4 +- ...leQueryScalarHydrationPerformanceBench.php | 4 +- tests/Tests/Mocks/ArrayResultFactory.php | 42 +++++++++++++++++++ .../ORM/Functional/Ticket/GH6362Test.php | 5 +-- .../ORM/Functional/Ticket/GH9807Test.php | 5 +-- .../Tests/ORM/Hydration/HydrationTestCase.php | 4 +- .../ORM/Hydration/ObjectHydratorTest.php | 5 +-- tests/Tests/ORM/Query/QueryTest.php | 12 +++--- 13 files changed, 68 insertions(+), 47 deletions(-) delete mode 100644 tests/Performance/ArrayResultFactory.php create mode 100644 tests/Tests/Mocks/ArrayResultFactory.php diff --git a/tests/Performance/ArrayResultFactory.php b/tests/Performance/ArrayResultFactory.php deleted file mode 100644 index 72a2fad7ea6..00000000000 --- a/tests/Performance/ArrayResultFactory.php +++ /dev/null @@ -1,18 +0,0 @@ -result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ArrayHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinFullObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinFullObjectHydrationPerformanceBench.php index fad34c0521f..21fce4f4dff 100644 --- a/tests/Performance/Hydration/MixedQueryFetchJoinFullObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/MixedQueryFetchJoinFullObjectHydrationPerformanceBench.php @@ -7,8 +7,8 @@ use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Performance\ArrayResultFactory; use Doctrine\Performance\EntityManagerFactory; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsUser; @@ -49,7 +49,7 @@ public function init(): void ]; } - $this->result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); diff --git a/tests/Performance/Hydration/SimpleQueryArrayHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryArrayHydrationPerformanceBench.php index c51bac44a6f..e863107edf6 100644 --- a/tests/Performance/Hydration/SimpleQueryArrayHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/SimpleQueryArrayHydrationPerformanceBench.php @@ -7,8 +7,8 @@ use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\ArrayHydrator; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Performance\ArrayResultFactory; use Doctrine\Performance\EntityManagerFactory; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; @@ -53,7 +53,7 @@ public function init(): void ]; } - $this->result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ArrayHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); diff --git a/tests/Performance/Hydration/SimpleQueryFullObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryFullObjectHydrationPerformanceBench.php index 3b158c30373..8c1114ca07e 100644 --- a/tests/Performance/Hydration/SimpleQueryFullObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/SimpleQueryFullObjectHydrationPerformanceBench.php @@ -7,8 +7,8 @@ use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Performance\ArrayResultFactory; use Doctrine\Performance\EntityManagerFactory; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; @@ -44,7 +44,7 @@ public function init(): void ]; } - $this->result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); diff --git a/tests/Performance/Hydration/SimpleQueryScalarHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryScalarHydrationPerformanceBench.php index 0ed9fda0892..1761b9d3f80 100644 --- a/tests/Performance/Hydration/SimpleQueryScalarHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/SimpleQueryScalarHydrationPerformanceBench.php @@ -7,8 +7,8 @@ use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\ScalarHydrator; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Performance\ArrayResultFactory; use Doctrine\Performance\EntityManagerFactory; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; @@ -53,7 +53,7 @@ public function init(): void ]; } - $this->result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ScalarHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); diff --git a/tests/Tests/Mocks/ArrayResultFactory.php b/tests/Tests/Mocks/ArrayResultFactory.php new file mode 100644 index 00000000000..56a8110c613 --- /dev/null +++ b/tests/Tests/Mocks/ArrayResultFactory.php @@ -0,0 +1,42 @@ +> $resultSet */ + public static function createDriverResultFromArray(array $resultSet): ArrayResult + { + if ((new ReflectionMethod(ArrayResult::class, '__construct'))->getNumberOfRequiredParameters() < 2) { + // DBAL < 4.2 + return new ArrayResult($resultSet); + } + + // DBAL 4.2+ + return new ArrayResult( + array_keys($resultSet[0] ?? []), + array_map(array_values(...), $resultSet), + ); + } + + /** @param list> $resultSet */ + public static function createWrapperResultFromArray(array $resultSet, Connection|null $connection = null): Result + { + return new Result( + self::createDriverResultFromArray($resultSet), + $connection ?? new Connection([], new Driver()), + ); + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/GH6362Test.php b/tests/Tests/ORM/Functional/Ticket/GH6362Test.php index 9c45cc9885f..11f8f5ec632 100644 --- a/tests/Tests/ORM/Functional/Ticket/GH6362Test.php +++ b/tests/Tests/ORM/Functional/Ticket/GH6362Test.php @@ -5,9 +5,7 @@ namespace Doctrine\Tests\ORM\Functional\Ticket; use Doctrine\Common\Collections\Collection; -use Doctrine\DBAL\Cache\ArrayResult; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\DiscriminatorColumn; @@ -19,6 +17,7 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\Group; @@ -78,7 +77,7 @@ public function testInheritanceJoinAlias(): void ], ]; - $stmt = new Result(new ArrayResult($resultSet), $this->createMock(Connection::class)); + $stmt = ArrayResultFactory::createWrapperResultFromArray($resultSet, $this->createMock(Connection::class)); $hydrator = new ObjectHydrator($this->_em); $result = $hydrator->hydrateAll($stmt, $rsm); diff --git a/tests/Tests/ORM/Functional/Ticket/GH9807Test.php b/tests/Tests/ORM/Functional/Ticket/GH9807Test.php index 9d8e8f1c51b..17b43256662 100644 --- a/tests/Tests/ORM/Functional/Ticket/GH9807Test.php +++ b/tests/Tests/ORM/Functional/Ticket/GH9807Test.php @@ -5,9 +5,7 @@ namespace Doctrine\Tests\ORM\Functional\Ticket; use Doctrine\Common\Collections\Collection; -use Doctrine\DBAL\Cache\ArrayResult; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\Column; @@ -15,6 +13,7 @@ use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\OrmFunctionalTestCase; final class GH9807Test extends OrmFunctionalTestCase @@ -63,7 +62,7 @@ public function testHydrateJoinedCollectionWithFirstNullishRow(): void ], ]; - $stmt = new Result(new ArrayResult($resultSet), $this->createMock(Connection::class)); + $stmt = ArrayResultFactory::createWrapperResultFromArray($resultSet, $this->createMock(Connection::class)); /** @var GH9807Main[] $result */ $result = $hydrator->hydrateAll($stmt, $rsm); diff --git a/tests/Tests/ORM/Hydration/HydrationTestCase.php b/tests/Tests/ORM/Hydration/HydrationTestCase.php index fdad779d060..4e8e9709257 100644 --- a/tests/Tests/ORM/Hydration/HydrationTestCase.php +++ b/tests/Tests/ORM/Hydration/HydrationTestCase.php @@ -4,9 +4,9 @@ namespace Doctrine\Tests\ORM\Hydration; -use Doctrine\DBAL\Cache\ArrayResult; use Doctrine\DBAL\Result; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\OrmTestCase; class HydrationTestCase extends OrmTestCase @@ -22,6 +22,6 @@ protected function setUp(): void protected function createResultMock(array $resultSet): Result { - return new Result(new ArrayResult($resultSet), $this->entityManager->getConnection()); + return ArrayResultFactory::createWrapperResultFromArray($resultSet, $this->entityManager->getConnection()); } } diff --git a/tests/Tests/ORM/Hydration/ObjectHydratorTest.php b/tests/Tests/ORM/Hydration/ObjectHydratorTest.php index 977028da875..0a1f369bd1f 100644 --- a/tests/Tests/ORM/Hydration/ObjectHydratorTest.php +++ b/tests/Tests/ORM/Hydration/ObjectHydratorTest.php @@ -4,9 +4,7 @@ namespace Doctrine\Tests\ORM\Hydration; -use Doctrine\DBAL\Cache\ArrayResult; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Result; use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Mapping\ClassMetadata; @@ -14,6 +12,7 @@ use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\ORM\Proxy\ProxyFactory; use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsArticle; use Doctrine\Tests\Models\CMS\CmsComment; @@ -1337,7 +1336,7 @@ public function testResultIterationWithAliasedUserEntity(): void $hydrator = new ObjectHydrator($this->entityManager); $rowNum = 0; $iterableResult = $hydrator->toIterable( - new Result(new ArrayResult($resultSet), $this->createMock(Connection::class)), + ArrayResultFactory::createWrapperResultFromArray($resultSet, $this->createMock(Connection::class)), $rsm, ); diff --git a/tests/Tests/ORM/Query/QueryTest.php b/tests/Tests/ORM/Query/QueryTest.php index 961aafd32ed..03acc8ce0d0 100644 --- a/tests/Tests/ORM/Query/QueryTest.php +++ b/tests/Tests/ORM/Query/QueryTest.php @@ -8,7 +8,6 @@ use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\ArrayParameterType; -use Doctrine\DBAL\Cache\ArrayResult; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver; use Doctrine\DBAL\Driver\Result; @@ -22,6 +21,7 @@ use Doctrine\ORM\Query\QueryException; use Doctrine\ORM\UnitOfWork; use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsGroup; @@ -361,10 +361,10 @@ public function testResultCacheCaching(): void { $entityManager = $this->createTestEntityManagerWithConnection( $this->createConnection( - new ArrayResult([ + ArrayResultFactory::createDriverResultFromArray([ ['id_0' => 1], ]), - new ArrayResult([]), + ArrayResultFactory::createDriverResultFromArray([]), ), ); @@ -399,14 +399,14 @@ public function testResultCacheEviction(): void { $entityManager = $this->createTestEntityManagerWithConnection( $this->createConnection( - new ArrayResult([ + ArrayResultFactory::createDriverResultFromArray([ ['id_0' => 1], ]), - new ArrayResult([ + ArrayResultFactory::createDriverResultFromArray([ ['id_0' => 1], ['id_0' => 2], ]), - new ArrayResult([ + ArrayResultFactory::createDriverResultFromArray([ ['id_0' => 1], ]), ), From 25d5bc5b4695072692e5dc045d51ba5e1a34457c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Fri, 27 Sep 2024 19:32:00 +0200 Subject: [PATCH 24/31] Move orphan metadata to where it belongs The goal here was to retain compatibility with doctrine/rst-parser, which is no longer in use in the website. --- .github/workflows/documentation.yml | 5 ----- docs/en/reference/installation.rst | 2 ++ docs/en/sidebar.rst | 2 ++ 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 65cbad613b2..96caa7eba00 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -40,10 +40,5 @@ jobs: with: dependency-versions: "highest" - - name: "Add orphan metadata where needed" - run: | - printf '%s\n\n%s\n' ":orphan:" "$(cat docs/en/sidebar.rst)" > docs/en/sidebar.rst - printf '%s\n\n%s\n' ":orphan:" "$(cat docs/en/reference/installation.rst)" > docs/en/reference/installation.rst - - name: "Run guides-cli" run: "vendor/bin/guides -vvv --no-progress docs/en 2>&1 | grep -v 'No template found for rendering directive' | ( ! grep WARNING )" diff --git a/docs/en/reference/installation.rst b/docs/en/reference/installation.rst index dab1364f777..2c0d5823009 100644 --- a/docs/en/reference/installation.rst +++ b/docs/en/reference/installation.rst @@ -1,3 +1,5 @@ +:orphan: + Installation ============ diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index df3032d65f2..f67304e8457 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -1,3 +1,5 @@ +:orphan: + .. toc:: .. tocheader:: Tutorials From e6961bd9684048bd98ef6c8011f56c5096a9a614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Fri, 27 Sep 2024 19:19:05 +0200 Subject: [PATCH 25/31] Install guides-cli as a dev requirement It is better if contributors can check the docs by themselves. --- .github/workflows/documentation.yml | 11 ----------- composer.json | 1 + 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 65cbad613b2..cccf523278b 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -29,17 +29,6 @@ jobs: coverage: "none" php-version: "8.3" - - name: "Remove existing composer file" - run: "rm composer.json" - - - name: "Require phpdocumentor/guides-cli" - run: "composer require --dev phpdocumentor/guides-cli --no-update" - - - name: "Install dependencies with Composer" - uses: "ramsey/composer-install@v3" - with: - dependency-versions: "highest" - - name: "Add orphan metadata where needed" run: | printf '%s\n\n%s\n' ":orphan:" "$(cat docs/en/sidebar.rst)" > docs/en/sidebar.rst diff --git a/composer.json b/composer.json index 2a751261861..e904efe6f9c 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "require-dev": { "doctrine/coding-standard": "^12.0", "phpbench/phpbench": "^1.0", + "phpdocumentor/guides-cli": "^1.4", "phpstan/phpstan": "1.11.1", "phpunit/phpunit": "^10.4.0", "psr/log": "^1 || ^2 || ^3", From 2432939e4fadc44bf5309be5f7d3cebd25499734 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 09:04:06 +0200 Subject: [PATCH 26/31] Bump doctrine/.github from 5.0.1 to 5.1.0 (#11616) Bumps [doctrine/.github](https://github.com/doctrine/.github) from 5.0.1 to 5.1.0. - [Release notes](https://github.com/doctrine/.github/releases) - [Commits](https://github.com/doctrine/.github/compare/5.0.1...5.1.0) --- updated-dependencies: - dependency-name: doctrine/.github dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coding-standards.yml | 2 +- .github/workflows/release-on-milestone-closed.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 659da17bac6..390bdb5afc2 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -24,4 +24,4 @@ on: jobs: coding-standards: - uses: "doctrine/.github/.github/workflows/coding-standards.yml@5.0.1" + uses: "doctrine/.github/.github/workflows/coding-standards.yml@5.1.0" diff --git a/.github/workflows/release-on-milestone-closed.yml b/.github/workflows/release-on-milestone-closed.yml index 89d4fe8bf1c..d54a784ebe5 100644 --- a/.github/workflows/release-on-milestone-closed.yml +++ b/.github/workflows/release-on-milestone-closed.yml @@ -7,7 +7,7 @@ on: jobs: release: - uses: "doctrine/.github/.github/workflows/release-on-milestone-closed.yml@5.0.1" + uses: "doctrine/.github/.github/workflows/release-on-milestone-closed.yml@5.1.0" secrets: GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} From b7fd8241cf899a762cfccd980cc4e1657d7b78d4 Mon Sep 17 00:00:00 2001 From: n0099 Date: Tue, 1 Oct 2024 13:04:26 +0000 Subject: [PATCH 27/31] Update attributes-reference.rst --- docs/en/reference/attributes-reference.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index 795eeebf31c..8709cda6279 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -14,7 +14,7 @@ Index - :ref:`#[AttributeOverride] ` - :ref:`#[Column] ` - :ref:`#[Cache] ` -- :ref:`#[ChangeTrackingPolicy ` +- :ref:`#[ChangeTrackingPolicy] ` - :ref:`#[CustomIdGenerator] ` - :ref:`#[DiscriminatorColumn] ` - :ref:`#[DiscriminatorMap] ` From 7f0a181e39175788b513cf2d69e315d24da08dc4 Mon Sep 17 00:00:00 2001 From: eltharin Date: Wed, 2 Oct 2024 09:48:03 +0200 Subject: [PATCH 28/31] add nested new in EBNF documentation --- docs/en/reference/dql-doctrine-query-language.rst | 2 +- src/Query/Parser.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index c2b31cd326d..ab3cb138889 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -1627,7 +1627,7 @@ Select Expressions PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" - NewObjectArg ::= ScalarExpression | "(" Subselect ")" + NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression Conditional Expressions ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 38858cbf876..875783c87d4 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1765,7 +1765,7 @@ public function NewObjectExpression(): AST\NewObjectExpression } /** - * NewObjectArg ::= ScalarExpression | "(" Subselect ")" + * NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression */ public function NewObjectArg(): mixed { From b13564c6c063b7adc4a610c0ad4ff8bda0b90fe2 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 8 Oct 2024 12:25:31 +0200 Subject: [PATCH 29/31] Make nullable parameters explicit in generated entities (#11625) --- src/Tools/EntityGenerator.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Tools/EntityGenerator.php b/src/Tools/EntityGenerator.php index 04be6551881..3c72da123aa 100644 --- a/src/Tools/EntityGenerator.php +++ b/src/Tools/EntityGenerator.php @@ -767,6 +767,9 @@ private function generateEmbeddableConstructor(ClassMetadataInfo $metadata): str if ($fieldMapping['type'] === 'datetime') { $param = $this->getType($fieldMapping['type']) . ' ' . $param; + if (! empty($fieldMapping['nullable'])) { + $param = '?' . $param; + } } if (! empty($fieldMapping['nullable'])) { @@ -1385,6 +1388,9 @@ protected function generateEntityStubMethod(ClassMetadataInfo $metadata, $type, if ($typeHint && ! isset($types[$typeHint])) { $variableType = '\\' . ltrim($variableType, '\\'); $methodTypeHint = '\\' . $typeHint . ' '; + if ($defaultValue === 'null') { + $methodTypeHint = '?' . $methodTypeHint; + } } $replacements = [ From cc28fed9f51f24b7d0677d97b7ad67bc342bf728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 8 Oct 2024 14:41:35 +0200 Subject: [PATCH 30/31] Replace custom directives with native option --- .github/workflows/documentation.yml | 38 ++----- docs/en/sidebar.rst | 149 +++++++++++++--------------- 2 files changed, 77 insertions(+), 110 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 96caa7eba00..e9b2c71857d 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -5,40 +5,16 @@ on: branches: - "*.x" paths: - - .github/workflows/documentation.yml - - docs/** + - ".github/workflows/documentation.yml" + - "docs/**" push: branches: - "*.x" paths: - - .github/workflows/documentation.yml - - docs/** + - ".github/workflows/documentation.yml" + - "docs/**" jobs: - validate-with-guides: - name: "Validate documentation with phpDocumentor/guides" - runs-on: "ubuntu-22.04" - - steps: - - name: "Checkout code" - uses: "actions/checkout@v4" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "none" - php-version: "8.3" - - - name: "Remove existing composer file" - run: "rm composer.json" - - - name: "Require phpdocumentor/guides-cli" - run: "composer require --dev phpdocumentor/guides-cli --no-update" - - - name: "Install dependencies with Composer" - uses: "ramsey/composer-install@v3" - with: - dependency-versions: "highest" - - - name: "Run guides-cli" - run: "vendor/bin/guides -vvv --no-progress docs/en 2>&1 | grep -v 'No template found for rendering directive' | ( ! grep WARNING )" + documentation: + name: "Documentation" + uses: "doctrine/.github/.github/workflows/documentation.yml@5.1.0" diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index f67304e8457..b94c81fdd48 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -1,86 +1,77 @@ :orphan: -.. toc:: +.. toctree:: + :caption: Tutorials + :depth: 3 - .. tocheader:: Tutorials + tutorials/getting-started + tutorials/getting-started-database + tutorials/getting-started-models + tutorials/working-with-indexed-associations + tutorials/extra-lazy-associations + tutorials/composite-primary-keys + tutorials/ordered-associations + tutorials/override-field-association-mappings-in-subclasses + tutorials/pagination + tutorials/embeddables - .. toctree:: - :depth: 3 +.. toctree:: + :caption: Reference + :depth: 3 - tutorials/getting-started - tutorials/getting-started-database - tutorials/getting-started-models - tutorials/working-with-indexed-associations - tutorials/extra-lazy-associations - tutorials/composite-primary-keys - tutorials/ordered-associations - tutorials/override-field-association-mappings-in-subclasses - tutorials/pagination - tutorials/embeddables + reference/architecture + reference/configuration + reference/faq + reference/basic-mapping + reference/association-mapping + reference/inheritance-mapping + reference/working-with-objects + reference/working-with-associations + reference/typedfieldmapper + reference/events + reference/unitofwork + reference/unitofwork-associations + reference/transactions-and-concurrency + reference/batch-processing + reference/dql-doctrine-query-language + reference/query-builder + reference/native-sql + reference/change-tracking-policies + reference/partial-objects + reference/annotations-reference + reference/attributes-reference + reference/xml-mapping + reference/yaml-mapping + reference/php-mapping + reference/caching + reference/improving-performance + reference/tools + reference/metadata-drivers + reference/best-practices + reference/limitations-and-known-issues + tutorials/pagination + reference/filters + reference/namingstrategy + reference/advanced-configuration + reference/second-level-cache + reference/security -.. toc:: +.. toctree:: + :caption: Cookbook + :depth: 3 - .. tocheader:: Reference - - .. toctree:: - :depth: 3 - - reference/architecture - reference/configuration - reference/faq - reference/basic-mapping - reference/association-mapping - reference/inheritance-mapping - reference/working-with-objects - reference/working-with-associations - reference/typedfieldmapper - reference/events - reference/unitofwork - reference/unitofwork-associations - reference/transactions-and-concurrency - reference/batch-processing - reference/dql-doctrine-query-language - reference/query-builder - reference/native-sql - reference/change-tracking-policies - reference/partial-objects - reference/annotations-reference - reference/attributes-reference - reference/xml-mapping - reference/yaml-mapping - reference/php-mapping - reference/caching - reference/improving-performance - reference/tools - reference/metadata-drivers - reference/best-practices - reference/limitations-and-known-issues - tutorials/pagination - reference/filters - reference/namingstrategy - reference/advanced-configuration - reference/second-level-cache - reference/security - -.. toc:: - - .. tocheader:: Cookbook - - .. toctree:: - :depth: 3 - - cookbook/aggregate-fields - cookbook/custom-mapping-types - cookbook/decorator-pattern - cookbook/dql-custom-walkers - cookbook/dql-user-defined-functions - cookbook/implementing-arrayaccess-for-domain-objects - cookbook/implementing-the-notify-changetracking-policy - cookbook/resolve-target-entity-listener - cookbook/sql-table-prefixes - cookbook/strategy-cookbook-introduction - cookbook/validation-of-entities - cookbook/working-with-datetime - cookbook/mysql-enums - cookbook/advanced-field-value-conversion-using-custom-mapping-types - cookbook/entities-in-session + cookbook/aggregate-fields + cookbook/custom-mapping-types + cookbook/decorator-pattern + cookbook/dql-custom-walkers + cookbook/dql-user-defined-functions + cookbook/implementing-arrayaccess-for-domain-objects + cookbook/implementing-the-notify-changetracking-policy + cookbook/resolve-target-entity-listener + cookbook/sql-table-prefixes + cookbook/strategy-cookbook-introduction + cookbook/validation-of-entities + cookbook/working-with-datetime + cookbook/mysql-enums + cookbook/advanced-field-value-conversion-using-custom-mapping-types + cookbook/entities-in-session From b5c0e0154f6453c2ca0510fc15001c747b7eabb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Wed, 9 Oct 2024 10:29:38 +0200 Subject: [PATCH 31/31] Remove int from union type This case is no longer necessary now that we have a ParameterType enum. --- src/QueryBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 403ccb66bf7..d5d1863f0de 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1361,8 +1361,8 @@ public function resetDQLPart(string $part): static * ->orWhere('u.username = ' . $qb->createNamedParameter('Bar', Types::STRING)) * * - * @param ParameterType|ArrayParameterType|string|int|null $type ParameterType::*, ArrayParameterType::* or \Doctrine\DBAL\Types\Type::* constant - * @param non-empty-string|null $placeholder The name to bind with. The string must start with a colon ':'. + * @param ParameterType|ArrayParameterType|string|null $type ParameterType::*, ArrayParameterType::* or \Doctrine\DBAL\Types\Type::* constant + * @param non-empty-string|null $placeholder The name to bind with. The string must start with a colon ':'. * * @return non-empty-string the placeholder name used. */