From 5aca74e4ad9f8cf70b491aee488a8dd1d1bef9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 15 Dec 2025 17:08:09 +0100 Subject: [PATCH 1/4] Add readOnly parameter to preload() for readonly queries Adds support for setting Query::HINT_READ_ONLY on inner queries via a new optional `readOnly` parameter on the preload() method. When enabled, preloaded entities are marked as read-only in the UnitOfWork, improving performance for read-only operations by disabling change tracking. Closes #36 --- src/EntityPreloader.php | 51 ++++++++++++++++++---- tests/EntityPreloadBlogManyHasManyTest.php | 23 ++++++++++ tests/EntityPreloadBlogManyHasOneTest.php | 22 ++++++++++ tests/EntityPreloadBlogOneHasManyTest.php | 22 ++++++++++ 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/EntityPreloader.php b/src/EntityPreloader.php index ed6f3ed..064f26b 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); @@ -63,7 +65,7 @@ public function preload( } $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, $readOnly); $preloader = match ($associationMapping['type']) { ClassMetadata::ONE_TO_ONE, ClassMetadata::MANY_TO_ONE => $this->preloadToOne(...), @@ -71,7 +73,19 @@ public function preload( default => throw new LogicException("Unsupported association mapping type {$associationMapping['type']}"), }; - return $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount); + $result = $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); + + if ($readOnly) { + $unitOfWork = $this->entityManager->getUnitOfWork(); + + foreach ($result as $entity) { + if (!$unitOfWork->isReadOnly($entity)) { + $unitOfWork->markReadOnly($entity); + } + } + } + + return $result; } /** @@ -114,6 +128,7 @@ private function loadProxies( array $entities, int $batchSize, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { $identifierAccessor = $this->getSingleIdPropertyAccessor($classMetadata); // e.g. Order::$id reflection @@ -137,7 +152,7 @@ private function loadProxies( } foreach (array_chunk($uninitializedIds, $batchSize) as $idsChunk) { - $this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount); + $this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount, readOnly: $readOnly); } return array_values($uniqueEntities); @@ -158,6 +173,7 @@ private function preloadToMany( ClassMetadata $targetClassMetadata, ?int $batchSize, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { $sourceIdentifierAccessor = $this->getSingleIdPropertyAccessor($sourceClassMetadata); // e.g. Order::$id reflection @@ -213,6 +229,7 @@ private function preloadToMany( uninitializedSourceEntityIdsChunk: array_values($uninitializedSourceEntityIdsChunk), uninitializedCollections: $uninitializedCollections, maxFetchJoinSameFieldCount: $maxFetchJoinSameFieldCount, + readOnly: $readOnly, ); foreach ($targetEntitiesChunk as $targetEntityKey => $targetEntity) { @@ -247,6 +264,7 @@ private function preloadOneToManyInner( array $uninitializedSourceEntityIdsChunk, array $uninitializedCollections, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { $targetPropertyName = $sourceClassMetadata->getAssociationMappedByTargetField($sourcePropertyName); // e.g. 'order' @@ -264,6 +282,7 @@ private function preloadOneToManyInner( $uninitializedSourceEntityIdsChunk, $maxFetchJoinSameFieldCount, $associationMapping['orderBy'] ?? [], + $readOnly, ); foreach ($targetEntitiesList as $targetEntity) { @@ -297,6 +316,7 @@ private function preloadManyToManyInner( array $uninitializedSourceEntityIdsChunk, array $uninitializedCollections, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { if (count($associationMapping['orderBy'] ?? []) > 0) { @@ -308,7 +328,7 @@ private function preloadManyToManyInner( $sourceIdentifierType = $this->getIdentifierFieldType($sourceClassMetadata); - $manyToManyRows = $this->entityManager->createQueryBuilder() + $manyToManyQuery = $this->entityManager->createQueryBuilder() ->select("source.{$sourceIdentifierName} AS sourceId", "target.{$targetIdentifierName} AS targetId") ->from($sourceClassMetadata->getName(), 'source') ->join("source.{$sourcePropertyName}", 'target') @@ -318,8 +338,13 @@ private function preloadManyToManyInner( $this->convertFieldValuesToDatabaseValues($sourceIdentifierType, $uninitializedSourceEntityIdsChunk), $this->deduceArrayParameterType($sourceIdentifierType), ) - ->getQuery() - ->getResult(); + ->getQuery(); + + if ($readOnly) { + $manyToManyQuery->setHint(Query::HINT_READ_ONLY, true); + } + + $manyToManyRows = $manyToManyQuery->getResult(); $targetEntities = []; $uninitializedTargetEntityIds = []; @@ -339,7 +364,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; } @@ -368,6 +393,7 @@ private function preloadToOne( ClassMetadata $targetClassMetadata, ?int $batchSize, int $maxFetchJoinSameFieldCount, + bool $readOnly, ): array { $sourcePropertyAccessor = $this->getPropertyAccessor($sourceClassMetadata, $sourcePropertyName); // e.g. Item::$order reflection @@ -389,7 +415,7 @@ private function preloadToOne( $targetEntities[] = $targetEntity; } - return $this->loadProxies($targetClassMetadata, $targetEntities, $batchSize, $maxFetchJoinSameFieldCount); + return $this->loadProxies($targetClassMetadata, $targetEntities, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); } /** @@ -407,6 +433,7 @@ private function loadEntitiesBy( array $fieldValues, int $maxFetchJoinSameFieldCount, array $orderBy = [], + bool $readOnly = false, ): array { if (count($fieldValues) === 0) { @@ -432,7 +459,13 @@ private function loadEntitiesBy( $queryBuilder->addOrderBy("{$rootLevelAlias}.{$field}", $direction); } - return $queryBuilder->getQuery()->getResult(); + $query = $queryBuilder->getQuery(); + + if ($readOnly) { + $query->setHint(Query::HINT_READ_ONLY, true); + } + + return $query->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..36b8859 100644 --- a/tests/EntityPreloadBlogManyHasOneTest.php +++ b/tests/EntityPreloadBlogManyHasOneTest.php @@ -118,6 +118,28 @@ public function testManyHasOneWithPreload(DbalType $primaryKey): void ]); } + #[DataProvider('providePrimaryKeyTypes')] + public function testManyHasOneWithPreloadReadOnly(DbalType $primaryKey): void + { + $this->createDummyBlogData($primaryKey, categoryCount: 5, articleInEachCategoryCount: 5); + + $articles = $this->getEntityManager()->getRepository(Article::class)->findAll(); + $categories = $this->getEntityPreloader()->preload($articles, 'category', readOnly: true); + + self::assertCount(5, $categories); + + // Verify that preloaded categories are marked as read-only + $unitOfWork = $this->getEntityManager()->getUnitOfWork(); + foreach ($categories as $category) { + self::assertTrue($unitOfWork->isReadOnly($category), 'Category should be marked as read-only'); + } + + self::assertAggregatedQueries([ + ['count' => 1, 'query' => 'SELECT * FROM article t0'], + ['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.id IN (?, ?, ?, ?, ?)'], + ]); + } + /** * @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 */ From dbad2f0c8ec558c1053ee11afc5be1dda1fbf714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 15 Dec 2025 17:18:12 +0100 Subject: [PATCH 2/4] Add readOnly parameter documentation to README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 09ac767..9f4d60c 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. ```php $preloader->preload( $articles, 'category', batchSize: 20, - maxFetchJoinSameFieldCount: 5 + maxFetchJoinSameFieldCount: 5, + readOnly: true, ); ``` From 9cdfecfca127db0f6ed3727241fe8493998c6c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 15 Dec 2025 17:25:21 +0100 Subject: [PATCH 3/4] Address review feedback: simplify setHint calls, format loadProxies --- src/EntityPreloader.php | 45 ++++++++-------------- tests/EntityPreloadBlogManyHasManyTest.php | 6 --- tests/EntityPreloadBlogManyHasOneTest.php | 6 --- tests/EntityPreloadBlogOneHasManyTest.php | 6 --- 4 files changed, 16 insertions(+), 47 deletions(-) diff --git a/src/EntityPreloader.php b/src/EntityPreloader.php index 064f26b..0c06df0 100644 --- a/src/EntityPreloader.php +++ b/src/EntityPreloader.php @@ -65,7 +65,13 @@ public function preload( } $maxFetchJoinSameFieldCount ??= 1; - $sourceEntities = $this->loadProxies($sourceClassMetadata, $sourceEntities, $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, $maxFetchJoinSameFieldCount, $readOnly); + $sourceEntities = $this->loadProxies( + $sourceClassMetadata, + $sourceEntities, + $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, + $maxFetchJoinSameFieldCount, + $readOnly, + ); $preloader = match ($associationMapping['type']) { ClassMetadata::ONE_TO_ONE, ClassMetadata::MANY_TO_ONE => $this->preloadToOne(...), @@ -73,19 +79,7 @@ public function preload( default => throw new LogicException("Unsupported association mapping type {$associationMapping['type']}"), }; - $result = $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); - - if ($readOnly) { - $unitOfWork = $this->entityManager->getUnitOfWork(); - - foreach ($result as $entity) { - if (!$unitOfWork->isReadOnly($entity)) { - $unitOfWork->markReadOnly($entity); - } - } - } - - return $result; + return $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); } /** @@ -328,7 +322,7 @@ private function preloadManyToManyInner( $sourceIdentifierType = $this->getIdentifierFieldType($sourceClassMetadata); - $manyToManyQuery = $this->entityManager->createQueryBuilder() + $manyToManyRows = $this->entityManager->createQueryBuilder() ->select("source.{$sourceIdentifierName} AS sourceId", "target.{$targetIdentifierName} AS targetId") ->from($sourceClassMetadata->getName(), 'source') ->join("source.{$sourcePropertyName}", 'target') @@ -338,13 +332,9 @@ private function preloadManyToManyInner( $this->convertFieldValuesToDatabaseValues($sourceIdentifierType, $uninitializedSourceEntityIdsChunk), $this->deduceArrayParameterType($sourceIdentifierType), ) - ->getQuery(); - - if ($readOnly) { - $manyToManyQuery->setHint(Query::HINT_READ_ONLY, true); - } - - $manyToManyRows = $manyToManyQuery->getResult(); + ->getQuery() + ->setHint(Query::HINT_READ_ONLY, $readOnly) + ->getResult(); $targetEntities = []; $uninitializedTargetEntityIds = []; @@ -459,13 +449,10 @@ private function loadEntitiesBy( $queryBuilder->addOrderBy("{$rootLevelAlias}.{$field}", $direction); } - $query = $queryBuilder->getQuery(); - - if ($readOnly) { - $query->setHint(Query::HINT_READ_ONLY, true); - } - - return $query->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 bded0c0..17eadbe 100644 --- a/tests/EntityPreloadBlogManyHasManyTest.php +++ b/tests/EntityPreloadBlogManyHasManyTest.php @@ -125,12 +125,6 @@ public function testManyHasManyWithPreloadReadOnly(DbalType $primaryKey): void 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 (?, ?, ?, ?, ?)'], diff --git a/tests/EntityPreloadBlogManyHasOneTest.php b/tests/EntityPreloadBlogManyHasOneTest.php index 36b8859..dbe92c2 100644 --- a/tests/EntityPreloadBlogManyHasOneTest.php +++ b/tests/EntityPreloadBlogManyHasOneTest.php @@ -128,12 +128,6 @@ public function testManyHasOneWithPreloadReadOnly(DbalType $primaryKey): void self::assertCount(5, $categories); - // Verify that preloaded categories are marked as read-only - $unitOfWork = $this->getEntityManager()->getUnitOfWork(); - foreach ($categories as $category) { - self::assertTrue($unitOfWork->isReadOnly($category), 'Category should be marked as read-only'); - } - self::assertAggregatedQueries([ ['count' => 1, 'query' => 'SELECT * FROM article t0'], ['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.id IN (?, ?, ?, ?, ?)'], diff --git a/tests/EntityPreloadBlogOneHasManyTest.php b/tests/EntityPreloadBlogOneHasManyTest.php index c8ed116..482129b 100644 --- a/tests/EntityPreloadBlogOneHasManyTest.php +++ b/tests/EntityPreloadBlogOneHasManyTest.php @@ -149,12 +149,6 @@ public function testOneHasManyWithPreloadReadOnly(DbalType $primaryKey): void 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 (?, ?, ?, ?, ?)'], From c3c6892910711be5191c479dc4625c8af8459668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 15 Dec 2025 17:34:45 +0100 Subject: [PATCH 4/4] Throw LogicException when readOnly is used with toOne associations --- README.md | 2 +- src/EntityPreloader.php | 24 ++++++++++++---------- tests/EntityPreloadBlogManyHasManyTest.php | 6 ++++++ tests/EntityPreloadBlogManyHasOneTest.php | 14 ++++++------- tests/EntityPreloadBlogOneHasManyTest.php | 6 ++++++ 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9f4d60c..d6c447f 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ 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. +- **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( diff --git a/src/EntityPreloader.php b/src/EntityPreloader.php index 0c06df0..a52b1c6 100644 --- a/src/EntityPreloader.php +++ b/src/EntityPreloader.php @@ -64,22 +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, - $readOnly, ); - $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, $readOnly); + return $this->preloadToMany($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); } /** @@ -122,7 +126,6 @@ private function loadProxies( array $entities, int $batchSize, int $maxFetchJoinSameFieldCount, - bool $readOnly, ): array { $identifierAccessor = $this->getSingleIdPropertyAccessor($classMetadata); // e.g. Order::$id reflection @@ -146,7 +149,7 @@ private function loadProxies( } foreach (array_chunk($uninitializedIds, $batchSize) as $idsChunk) { - $this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount, readOnly: $readOnly); + $this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount); } return array_values($uniqueEntities); @@ -383,7 +386,6 @@ private function preloadToOne( ClassMetadata $targetClassMetadata, ?int $batchSize, int $maxFetchJoinSameFieldCount, - bool $readOnly, ): array { $sourcePropertyAccessor = $this->getPropertyAccessor($sourceClassMetadata, $sourcePropertyName); // e.g. Item::$order reflection @@ -405,7 +407,7 @@ private function preloadToOne( $targetEntities[] = $targetEntity; } - return $this->loadProxies($targetClassMetadata, $targetEntities, $batchSize, $maxFetchJoinSameFieldCount, $readOnly); + return $this->loadProxies($targetClassMetadata, $targetEntities, $batchSize, $maxFetchJoinSameFieldCount); } /** diff --git a/tests/EntityPreloadBlogManyHasManyTest.php b/tests/EntityPreloadBlogManyHasManyTest.php index 17eadbe..bded0c0 100644 --- a/tests/EntityPreloadBlogManyHasManyTest.php +++ b/tests/EntityPreloadBlogManyHasManyTest.php @@ -125,6 +125,12 @@ public function testManyHasManyWithPreloadReadOnly(DbalType $primaryKey): void 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 (?, ?, ?, ?, ?)'], diff --git a/tests/EntityPreloadBlogManyHasOneTest.php b/tests/EntityPreloadBlogManyHasOneTest.php index dbe92c2..e751024 100644 --- a/tests/EntityPreloadBlogManyHasOneTest.php +++ b/tests/EntityPreloadBlogManyHasOneTest.php @@ -119,19 +119,17 @@ public function testManyHasOneWithPreload(DbalType $primaryKey): void } #[DataProvider('providePrimaryKeyTypes')] - public function testManyHasOneWithPreloadReadOnly(DbalType $primaryKey): void + public function testManyHasOneWithPreloadReadOnlyThrowsException(DbalType $primaryKey): void { $this->createDummyBlogData($primaryKey, categoryCount: 5, articleInEachCategoryCount: 5); $articles = $this->getEntityManager()->getRepository(Article::class)->findAll(); - $categories = $this->getEntityPreloader()->preload($articles, 'category', readOnly: true); - self::assertCount(5, $categories); - - self::assertAggregatedQueries([ - ['count' => 1, 'query' => 'SELECT * FROM article t0'], - ['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.id IN (?, ?, ?, ?, ?)'], - ]); + self::assertException( + \LogicException::class, + 'The readOnly option is not supported for toOne associations', + fn () => $this->getEntityPreloader()->preload($articles, 'category', readOnly: true), + ); } /** diff --git a/tests/EntityPreloadBlogOneHasManyTest.php b/tests/EntityPreloadBlogOneHasManyTest.php index 482129b..c8ed116 100644 --- a/tests/EntityPreloadBlogOneHasManyTest.php +++ b/tests/EntityPreloadBlogOneHasManyTest.php @@ -149,6 +149,12 @@ public function testOneHasManyWithPreloadReadOnly(DbalType $primaryKey): void 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 (?, ?, ?, ?, ?)'],