diff --git a/composer.json b/composer.json index 521b85d..402bd76 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ ], "require": { "php": "^8.1", - "doctrine/orm": "^3" + "doctrine/orm": "^3.2" }, "require-dev": { "doctrine/collections": "^2.2", diff --git a/src/EntityPreloader.php b/src/EntityPreloader.php index bc0857d..49958aa 100644 --- a/src/EntityPreloader.php +++ b/src/EntityPreloader.php @@ -4,6 +4,9 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\ManyToManyAssociationMapping; +use Doctrine\ORM\Mapping\OneToManyAssociationMapping; +use Doctrine\ORM\Mapping\ToManyAssociationMapping; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\QueryBuilder; use LogicException; @@ -59,10 +62,6 @@ public function preload( throw new LogicException('Preloading of indexed associations is not supported'); } - if ($associationMapping->isOrdered()) { - throw new LogicException('Preloading of ordered associations is not supported'); - } - $maxFetchJoinSameFieldCount ??= 1; $sourceEntities = $this->loadProxies($sourceClassMetadata, $sourceEntities, $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, $maxFetchJoinSameFieldCount); @@ -200,14 +199,21 @@ private function preloadToMany( } } - $innerLoader = match ($sourceClassMetadata->getAssociationMapping($sourcePropertyName)->type()) { - ClassMetadata::ONE_TO_MANY => $this->preloadOneToManyInner(...), - ClassMetadata::MANY_TO_MANY => $this->preloadManyToManyInner(...), + $associationMapping = $sourceClassMetadata->getAssociationMapping($sourcePropertyName); + + if (!$associationMapping instanceof ToManyAssociationMapping) { + throw new LogicException('Unsupported association mapping type'); + } + + $innerLoader = match (true) { + $associationMapping instanceof OneToManyAssociationMapping => $this->preloadOneToManyInner(...), + $associationMapping instanceof ManyToManyAssociationMapping => $this->preloadManyToManyInner(...), default => throw new LogicException('Unsupported association mapping type'), }; foreach (array_chunk($uninitializedSourceEntityIds, $batchSize, preserve_keys: true) as $uninitializedSourceEntityIdsChunk) { $targetEntitiesChunk = $innerLoader( + associationMapping: $associationMapping, sourceClassMetadata: $sourceClassMetadata, sourceIdentifierReflection: $sourceIdentifierReflection, sourcePropertyName: $sourcePropertyName, @@ -242,6 +248,7 @@ private function preloadToMany( * @template T of E */ private function preloadOneToManyInner( + ToManyAssociationMapping $associationMapping, ClassMetadata $sourceClassMetadata, ReflectionProperty $sourceIdentifierReflection, string $sourcePropertyName, @@ -260,7 +267,15 @@ private function preloadOneToManyInner( throw new LogicException('Doctrine should use RuntimeReflectionService which never returns null.'); } - foreach ($this->loadEntitiesBy($targetClassMetadata, $targetPropertyName, $uninitializedSourceEntityIdsChunk, $maxFetchJoinSameFieldCount) as $targetEntity) { + $targetEntitiesList = $this->loadEntitiesBy( + $targetClassMetadata, + $targetPropertyName, + $uninitializedSourceEntityIdsChunk, + $maxFetchJoinSameFieldCount, + $associationMapping->orderBy(), + ); + + foreach ($targetEntitiesList as $targetEntity) { $sourceEntity = $targetPropertyReflection->getValue($targetEntity); $sourceEntityKey = (string) $sourceIdentifierReflection->getValue($sourceEntity); $uninitializedCollections[$sourceEntityKey]->add($targetEntity); @@ -283,6 +298,7 @@ private function preloadOneToManyInner( * @template T of E */ private function preloadManyToManyInner( + ToManyAssociationMapping $associationMapping, ClassMetadata $sourceClassMetadata, ReflectionProperty $sourceIdentifierReflection, string $sourcePropertyName, @@ -293,6 +309,10 @@ private function preloadManyToManyInner( int $maxFetchJoinSameFieldCount, ): array { + if (count($associationMapping->orderBy()) > 0) { + throw new LogicException('Many-to-many associations with order by are not supported'); + } + $sourceIdentifierName = $sourceClassMetadata->getSingleIdentifierFieldName(); $targetIdentifierName = $targetClassMetadata->getSingleIdentifierFieldName(); @@ -382,6 +402,7 @@ private function preloadToOne( * @param ClassMetadata $targetClassMetadata * @param list $fieldValues * @param non-negative-int $maxFetchJoinSameFieldCount + * @param array $orderBy * @return list * @template T of E */ @@ -390,6 +411,7 @@ private function loadEntitiesBy( string $fieldName, array $fieldValues, int $maxFetchJoinSameFieldCount, + array $orderBy = [], ): array { if (count($fieldValues) === 0) { @@ -406,6 +428,10 @@ private function loadEntitiesBy( $this->addFetchJoinsToPreventFetchDuringHydration($rootLevelAlias, $queryBuilder, $targetClassMetadata, $maxFetchJoinSameFieldCount); + foreach ($orderBy as $field => $direction) { + $queryBuilder->addOrderBy("{$rootLevelAlias}.{$field}", $direction); + } + return $queryBuilder->getQuery()->getResult(); } diff --git a/tests/EntityPreloadBlogOneHasManyAbstractTest.php b/tests/EntityPreloadBlogOneHasManyAbstractTest.php index 95ea11d..f048377 100644 --- a/tests/EntityPreloadBlogOneHasManyAbstractTest.php +++ b/tests/EntityPreloadBlogOneHasManyAbstractTest.php @@ -20,7 +20,7 @@ public function testOneHasManyAbstractUnoptimized(): void self::assertAggregatedQueries([ ['count' => 1, 'query' => 'SELECT * FROM article t0'], - ['count' => 5, 'query' => 'SELECT * FROM comment t0 WHERE t0.article_id = ?'], + ['count' => 5, 'query' => 'SELECT * FROM comment t0 WHERE t0.article_id = ? ORDER BY t0.id DESC'], ['count' => 25, 'query' => 'SELECT * FROM contributor t0 WHERE t0.id = ? AND t0.dtype IN (?)'], ]); } @@ -40,7 +40,7 @@ public function testOneHasManyAbstractWithFetchJoin(): void $this->readComments($articles); self::assertAggregatedQueries([ - ['count' => 1, 'query' => 'SELECT * FROM article a0_ LEFT JOIN comment c1_ ON a0_.id = c1_.article_id LEFT JOIN contributor c2_ ON c1_.author_id = c2_.id AND c2_.dtype IN (?)'], + ['count' => 1, 'query' => 'SELECT * FROM article a0_ LEFT JOIN comment c1_ ON a0_.id = c1_.article_id LEFT JOIN contributor c2_ ON c1_.author_id = c2_.id AND c2_.dtype IN (?) ORDER BY c1_.id DESC'], ]); } @@ -60,7 +60,7 @@ public function testOneHasManyAbstractWithEagerFetchMode(): void self::assertAggregatedQueries([ ['count' => 1, 'query' => 'SELECT * FROM article a0_'], - ['count' => 1, 'query' => 'SELECT * FROM comment t0 WHERE t0.article_id IN (?, ?, ?, ?, ?)'], + ['count' => 1, 'query' => 'SELECT * FROM comment t0 WHERE t0.article_id IN (?, ?, ?, ?, ?) ORDER BY t0.id DESC'], ['count' => 25, 'query' => 'SELECT * FROM contributor t0 WHERE t0.id = ? AND t0.dtype IN (?)'], ]); } @@ -76,7 +76,7 @@ public function testOneHasManyAbstractWithPreload(): void self::assertAggregatedQueries([ ['count' => 1, 'query' => 'SELECT * FROM article t0'], - ['count' => 1, 'query' => 'SELECT * FROM comment c0_ LEFT JOIN contributor c1_ ON c0_.author_id = c1_.id AND c1_.dtype IN (?) WHERE c0_.article_id IN (?, ?, ?, ?, ?)'], + ['count' => 1, 'query' => 'SELECT * FROM comment c0_ LEFT JOIN contributor c1_ ON c0_.author_id = c1_.id AND c1_.dtype IN (?) WHERE c0_.article_id IN (?, ?, ?, ?, ?) ORDER BY c0_.id DESC'], ]); } diff --git a/tests/Fixtures/Blog/Article.php b/tests/Fixtures/Blog/Article.php index 3aacf8a..b17dc26 100644 --- a/tests/Fixtures/Blog/Article.php +++ b/tests/Fixtures/Blog/Article.php @@ -12,6 +12,7 @@ use Doctrine\ORM\Mapping\ManyToMany; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; +use Doctrine\ORM\Mapping\OrderBy; #[Entity] class Article @@ -41,6 +42,7 @@ class Article * @var Collection */ #[OneToMany(targetEntity: Comment::class, mappedBy: 'article')] + #[OrderBy(['id' => 'DESC'])] private Collection $comments; public function __construct(string $title, string $content, ?Category $category = null)