diff --git a/README.md b/README.md index 09ac767..d6c447f 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,15 @@ foreach ($categories as $category) { - **Batch Size:** Set a custom batch size for preloading to optimize memory usage. - **Max Fetch Join Same Field Count:** Define the maximum number of join fetches allowed per field. +- **Read Only:** Mark preloaded entities as read-only to disable change tracking and improve performance (only supported for `#[OneToMany]` and `#[ManyToMany]` associations). ```php $preloader->preload( $articles, 'category', batchSize: 20, - maxFetchJoinSameFieldCount: 5 + maxFetchJoinSameFieldCount: 5, + readOnly: true, ); ``` diff --git a/src/EntityPreloader.php b/src/EntityPreloader.php index ed6f3ed..a52b1c6 100644 --- a/src/EntityPreloader.php +++ b/src/EntityPreloader.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\PropertyAccessors\PropertyAccessor; use Doctrine\ORM\PersistentCollection; +use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use LogicException; use ReflectionProperty; @@ -44,6 +45,7 @@ public function preload( string $sourcePropertyName, ?int $batchSize = null, ?int $maxFetchJoinSameFieldCount = null, + bool $readOnly = false, ): array { $sourceEntitiesCommonAncestor = $this->getCommonAncestor($sourceEntities); @@ -62,16 +64,26 @@ public function preload( throw new LogicException('Preloading of indexed associations is not supported'); } + $isToOne = $associationMapping['type'] === ClassMetadata::ONE_TO_ONE + || $associationMapping['type'] === ClassMetadata::MANY_TO_ONE; + + if ($readOnly && $isToOne) { + throw new LogicException('The readOnly option is not supported for toOne associations'); + } + $maxFetchJoinSameFieldCount ??= 1; - $sourceEntities = $this->loadProxies($sourceClassMetadata, $sourceEntities, $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, $maxFetchJoinSameFieldCount); + $sourceEntities = $this->loadProxies( + $sourceClassMetadata, + $sourceEntities, + $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, + $maxFetchJoinSameFieldCount, + ); - $preloader = match ($associationMapping['type']) { - ClassMetadata::ONE_TO_ONE, ClassMetadata::MANY_TO_ONE => $this->preloadToOne(...), - ClassMetadata::ONE_TO_MANY, ClassMetadata::MANY_TO_MANY => $this->preloadToMany(...), - default => throw new LogicException("Unsupported association mapping type {$associationMapping['type']}"), - }; + if ($isToOne) { + return $this->preloadToOne($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount); + } - return $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount); + return $this->preloadToMany($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); } /** @@ -158,6 +170,7 @@ private function preloadToMany( ClassMetadata $targetClassMetadata, ?int $batchSize, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { $sourceIdentifierAccessor = $this->getSingleIdPropertyAccessor($sourceClassMetadata); // e.g. Order::$id reflection @@ -213,6 +226,7 @@ private function preloadToMany( uninitializedSourceEntityIdsChunk: array_values($uninitializedSourceEntityIdsChunk), uninitializedCollections: $uninitializedCollections, maxFetchJoinSameFieldCount: $maxFetchJoinSameFieldCount, + readOnly: $readOnly, ); foreach ($targetEntitiesChunk as $targetEntityKey => $targetEntity) { @@ -247,6 +261,7 @@ private function preloadOneToManyInner( array $uninitializedSourceEntityIdsChunk, array $uninitializedCollections, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { $targetPropertyName = $sourceClassMetadata->getAssociationMappedByTargetField($sourcePropertyName); // e.g. 'order' @@ -264,6 +279,7 @@ private function preloadOneToManyInner( $uninitializedSourceEntityIdsChunk, $maxFetchJoinSameFieldCount, $associationMapping['orderBy'] ?? [], + $readOnly, ); foreach ($targetEntitiesList as $targetEntity) { @@ -297,6 +313,7 @@ private function preloadManyToManyInner( array $uninitializedSourceEntityIdsChunk, array $uninitializedCollections, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { if (count($associationMapping['orderBy'] ?? []) > 0) { @@ -319,6 +336,7 @@ private function preloadManyToManyInner( $this->deduceArrayParameterType($sourceIdentifierType), ) ->getQuery() + ->setHint(Query::HINT_READ_ONLY, $readOnly) ->getResult(); $targetEntities = []; @@ -339,7 +357,7 @@ private function preloadManyToManyInner( $uninitializedTargetEntityIds[$targetEntityKey] = $targetEntityId; } - foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, $sourceClassMetadata, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount) as $targetEntity) { + foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, $sourceClassMetadata, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount, readOnly: $readOnly) as $targetEntity) { $targetEntityKey = (string) $targetIdentifierAccessor->getValue($targetEntity); $targetEntities[$targetEntityKey] = $targetEntity; } @@ -407,6 +425,7 @@ private function loadEntitiesBy( array $fieldValues, int $maxFetchJoinSameFieldCount, array $orderBy = [], + bool $readOnly = false, ): array { if (count($fieldValues) === 0) { @@ -432,7 +451,10 @@ private function loadEntitiesBy( $queryBuilder->addOrderBy("{$rootLevelAlias}.{$field}", $direction); } - return $queryBuilder->getQuery()->getResult(); + return $queryBuilder + ->getQuery() + ->setHint(Query::HINT_READ_ONLY, $readOnly) + ->getResult(); } private function deduceArrayParameterType(Type $dbalType): ArrayParameterType|int|null // @phpstan-ignore return.unusedType (old dbal compat) diff --git a/tests/EntityPreloadBlogManyHasManyTest.php b/tests/EntityPreloadBlogManyHasManyTest.php index 0869ca9..bded0c0 100644 --- a/tests/EntityPreloadBlogManyHasManyTest.php +++ b/tests/EntityPreloadBlogManyHasManyTest.php @@ -115,6 +115,29 @@ public function testManyHasManyWithPreload(DbalType $primaryKey): void ]); } + #[DataProvider('providePrimaryKeyTypes')] + public function testManyHasManyWithPreloadReadOnly(DbalType $primaryKey): void + { + $this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5); + + $articles = $this->getEntityManager()->getRepository(Article::class)->findAll(); + $tags = $this->getEntityPreloader()->preload($articles, 'tags', readOnly: true); + + self::assertCount(25, $tags); + + // Verify that preloaded tags are marked as read-only + $unitOfWork = $this->getEntityManager()->getUnitOfWork(); + foreach ($tags as $tag) { + self::assertTrue($unitOfWork->isReadOnly($tag), 'Tag should be marked as read-only'); + } + + self::assertAggregatedQueries([ + ['count' => 1, 'query' => 'SELECT * FROM article t0'], + ['count' => 1, 'query' => 'SELECT * FROM article a0_ INNER JOIN article_tag a2_ ON a0_.id = a2_.article_id INNER JOIN tag t1_ ON t1_.id = a2_.tag_id WHERE a0_.id IN (?, ?, ?, ?, ?)'], + ['count' => 1, 'query' => 'SELECT * FROM tag t0_ WHERE t0_.id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'], + ]); + } + /** * @param array
$articles */ diff --git a/tests/EntityPreloadBlogManyHasOneTest.php b/tests/EntityPreloadBlogManyHasOneTest.php index 1e0082d..e751024 100644 --- a/tests/EntityPreloadBlogManyHasOneTest.php +++ b/tests/EntityPreloadBlogManyHasOneTest.php @@ -118,6 +118,20 @@ public function testManyHasOneWithPreload(DbalType $primaryKey): void ]); } + #[DataProvider('providePrimaryKeyTypes')] + public function testManyHasOneWithPreloadReadOnlyThrowsException(DbalType $primaryKey): void + { + $this->createDummyBlogData($primaryKey, categoryCount: 5, articleInEachCategoryCount: 5); + + $articles = $this->getEntityManager()->getRepository(Article::class)->findAll(); + + self::assertException( + \LogicException::class, + 'The readOnly option is not supported for toOne associations', + fn () => $this->getEntityPreloader()->preload($articles, 'category', readOnly: true), + ); + } + /** * @param array
$articles */ diff --git a/tests/EntityPreloadBlogOneHasManyTest.php b/tests/EntityPreloadBlogOneHasManyTest.php index ac02ffd..c8ed116 100644 --- a/tests/EntityPreloadBlogOneHasManyTest.php +++ b/tests/EntityPreloadBlogOneHasManyTest.php @@ -139,6 +139,28 @@ public function testOneHasManyWithPreload(DbalType $primaryKey): void ]); } + #[DataProvider('providePrimaryKeyTypes')] + public function testOneHasManyWithPreloadReadOnly(DbalType $primaryKey): void + { + $this->createDummyBlogData($primaryKey, categoryCount: 5, articleInEachCategoryCount: 5); + + $categories = $this->getEntityManager()->getRepository(Category::class)->findAll(); + $articles = $this->getEntityPreloader()->preload($categories, 'articles', readOnly: true); + + self::assertCount(25, $articles); + + // Verify that preloaded articles are marked as read-only + $unitOfWork = $this->getEntityManager()->getUnitOfWork(); + foreach ($articles as $article) { + self::assertTrue($unitOfWork->isReadOnly($article), 'Article should be marked as read-only'); + } + + self::assertAggregatedQueries([ + ['count' => 1, 'query' => 'SELECT * FROM category t0'], + ['count' => 1, 'query' => 'SELECT * FROM article a0_ WHERE a0_.category_id IN (?, ?, ?, ?, ?)'], + ]); + } + /** * @param array $categories */