Skip to content

Commit 79eff66

Browse files
committed
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
1 parent 7cbc70e commit 79eff66

File tree

4 files changed

+109
-9
lines changed

4 files changed

+109
-9
lines changed

src/EntityPreloader.php

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Doctrine\ORM\Mapping\ClassMetadata;
1111
use Doctrine\ORM\Mapping\PropertyAccessors\PropertyAccessor;
1212
use Doctrine\ORM\PersistentCollection;
13+
use Doctrine\ORM\Query;
1314
use Doctrine\ORM\QueryBuilder;
1415
use LogicException;
1516
use ReflectionProperty;
@@ -44,6 +45,7 @@ public function preload(
4445
string $sourcePropertyName,
4546
?int $batchSize = null,
4647
?int $maxFetchJoinSameFieldCount = null,
48+
bool $readOnly = false,
4749
): array
4850
{
4951
$sourceEntitiesCommonAncestor = $this->getCommonAncestor($sourceEntities);
@@ -63,15 +65,27 @@ public function preload(
6365
}
6466

6567
$maxFetchJoinSameFieldCount ??= 1;
66-
$sourceEntities = $this->loadProxies($sourceClassMetadata, $sourceEntities, $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, $maxFetchJoinSameFieldCount);
68+
$sourceEntities = $this->loadProxies($sourceClassMetadata, $sourceEntities, $batchSize ?? self::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE, $maxFetchJoinSameFieldCount, $readOnly);
6769

6870
$preloader = match ($associationMapping['type']) {
6971
ClassMetadata::ONE_TO_ONE, ClassMetadata::MANY_TO_ONE => $this->preloadToOne(...),
7072
ClassMetadata::ONE_TO_MANY, ClassMetadata::MANY_TO_MANY => $this->preloadToMany(...),
7173
default => throw new LogicException("Unsupported association mapping type {$associationMapping['type']}"),
7274
};
7375

74-
return $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount);
76+
$result = $preloader($sourceEntities, $sourceClassMetadata, $sourcePropertyName, $targetClassMetadata, $batchSize, $maxFetchJoinSameFieldCount, $readOnly);
77+
78+
if ($readOnly) {
79+
$unitOfWork = $this->entityManager->getUnitOfWork();
80+
81+
foreach ($result as $entity) {
82+
if (!$unitOfWork->isReadOnly($entity)) {
83+
$unitOfWork->markReadOnly($entity);
84+
}
85+
}
86+
}
87+
88+
return $result;
7589
}
7690

7791
/**
@@ -114,6 +128,7 @@ private function loadProxies(
114128
array $entities,
115129
int $batchSize,
116130
int $maxFetchJoinSameFieldCount,
131+
bool $readOnly,
117132
): array
118133
{
119134
$identifierAccessor = $this->getSingleIdPropertyAccessor($classMetadata); // e.g. Order::$id reflection
@@ -137,7 +152,7 @@ private function loadProxies(
137152
}
138153

139154
foreach (array_chunk($uninitializedIds, $batchSize) as $idsChunk) {
140-
$this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount);
155+
$this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount, readOnly: $readOnly);
141156
}
142157

143158
return array_values($uniqueEntities);
@@ -158,6 +173,7 @@ private function preloadToMany(
158173
ClassMetadata $targetClassMetadata,
159174
?int $batchSize,
160175
int $maxFetchJoinSameFieldCount,
176+
bool $readOnly,
161177
): array
162178
{
163179
$sourceIdentifierAccessor = $this->getSingleIdPropertyAccessor($sourceClassMetadata); // e.g. Order::$id reflection
@@ -213,6 +229,7 @@ private function preloadToMany(
213229
uninitializedSourceEntityIdsChunk: array_values($uninitializedSourceEntityIdsChunk),
214230
uninitializedCollections: $uninitializedCollections,
215231
maxFetchJoinSameFieldCount: $maxFetchJoinSameFieldCount,
232+
readOnly: $readOnly,
216233
);
217234

218235
foreach ($targetEntitiesChunk as $targetEntityKey => $targetEntity) {
@@ -247,6 +264,7 @@ private function preloadOneToManyInner(
247264
array $uninitializedSourceEntityIdsChunk,
248265
array $uninitializedCollections,
249266
int $maxFetchJoinSameFieldCount,
267+
bool $readOnly,
250268
): array
251269
{
252270
$targetPropertyName = $sourceClassMetadata->getAssociationMappedByTargetField($sourcePropertyName); // e.g. 'order'
@@ -264,6 +282,7 @@ private function preloadOneToManyInner(
264282
$uninitializedSourceEntityIdsChunk,
265283
$maxFetchJoinSameFieldCount,
266284
$associationMapping['orderBy'] ?? [],
285+
$readOnly,
267286
);
268287

269288
foreach ($targetEntitiesList as $targetEntity) {
@@ -297,6 +316,7 @@ private function preloadManyToManyInner(
297316
array $uninitializedSourceEntityIdsChunk,
298317
array $uninitializedCollections,
299318
int $maxFetchJoinSameFieldCount,
319+
bool $readOnly,
300320
): array
301321
{
302322
if (count($associationMapping['orderBy'] ?? []) > 0) {
@@ -308,7 +328,7 @@ private function preloadManyToManyInner(
308328

309329
$sourceIdentifierType = $this->getIdentifierFieldType($sourceClassMetadata);
310330

311-
$manyToManyRows = $this->entityManager->createQueryBuilder()
331+
$manyToManyQuery = $this->entityManager->createQueryBuilder()
312332
->select("source.{$sourceIdentifierName} AS sourceId", "target.{$targetIdentifierName} AS targetId")
313333
->from($sourceClassMetadata->getName(), 'source')
314334
->join("source.{$sourcePropertyName}", 'target')
@@ -318,8 +338,13 @@ private function preloadManyToManyInner(
318338
$this->convertFieldValuesToDatabaseValues($sourceIdentifierType, $uninitializedSourceEntityIdsChunk),
319339
$this->deduceArrayParameterType($sourceIdentifierType),
320340
)
321-
->getQuery()
322-
->getResult();
341+
->getQuery();
342+
343+
if ($readOnly) {
344+
$manyToManyQuery->setHint(Query::HINT_READ_ONLY, true);
345+
}
346+
347+
$manyToManyRows = $manyToManyQuery->getResult();
323348

324349
$targetEntities = [];
325350
$uninitializedTargetEntityIds = [];
@@ -339,7 +364,7 @@ private function preloadManyToManyInner(
339364
$uninitializedTargetEntityIds[$targetEntityKey] = $targetEntityId;
340365
}
341366

342-
foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, $sourceClassMetadata, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount) as $targetEntity) {
367+
foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, $sourceClassMetadata, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount, readOnly: $readOnly) as $targetEntity) {
343368
$targetEntityKey = (string) $targetIdentifierAccessor->getValue($targetEntity);
344369
$targetEntities[$targetEntityKey] = $targetEntity;
345370
}
@@ -368,6 +393,7 @@ private function preloadToOne(
368393
ClassMetadata $targetClassMetadata,
369394
?int $batchSize,
370395
int $maxFetchJoinSameFieldCount,
396+
bool $readOnly,
371397
): array
372398
{
373399
$sourcePropertyAccessor = $this->getPropertyAccessor($sourceClassMetadata, $sourcePropertyName); // e.g. Item::$order reflection
@@ -389,7 +415,7 @@ private function preloadToOne(
389415
$targetEntities[] = $targetEntity;
390416
}
391417

392-
return $this->loadProxies($targetClassMetadata, $targetEntities, $batchSize, $maxFetchJoinSameFieldCount);
418+
return $this->loadProxies($targetClassMetadata, $targetEntities, $batchSize, $maxFetchJoinSameFieldCount, $readOnly);
393419
}
394420

395421
/**
@@ -407,6 +433,7 @@ private function loadEntitiesBy(
407433
array $fieldValues,
408434
int $maxFetchJoinSameFieldCount,
409435
array $orderBy = [],
436+
bool $readOnly = false,
410437
): array
411438
{
412439
if (count($fieldValues) === 0) {
@@ -432,7 +459,13 @@ private function loadEntitiesBy(
432459
$queryBuilder->addOrderBy("{$rootLevelAlias}.{$field}", $direction);
433460
}
434461

435-
return $queryBuilder->getQuery()->getResult();
462+
$query = $queryBuilder->getQuery();
463+
464+
if ($readOnly) {
465+
$query->setHint(Query::HINT_READ_ONLY, true);
466+
}
467+
468+
return $query->getResult();
436469
}
437470

438471
private function deduceArrayParameterType(Type $dbalType): ArrayParameterType|int|null // @phpstan-ignore return.unusedType (old dbal compat)

tests/EntityPreloadBlogManyHasManyTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,29 @@ public function testManyHasManyWithPreload(DbalType $primaryKey): void
115115
]);
116116
}
117117

118+
#[DataProvider('providePrimaryKeyTypes')]
119+
public function testManyHasManyWithPreloadReadOnly(DbalType $primaryKey): void
120+
{
121+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
122+
123+
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
124+
$tags = $this->getEntityPreloader()->preload($articles, 'tags', readOnly: true);
125+
126+
self::assertCount(25, $tags);
127+
128+
// Verify that preloaded tags are marked as read-only
129+
$unitOfWork = $this->getEntityManager()->getUnitOfWork();
130+
foreach ($tags as $tag) {
131+
self::assertTrue($unitOfWork->isReadOnly($tag), 'Tag should be marked as read-only');
132+
}
133+
134+
self::assertAggregatedQueries([
135+
['count' => 1, 'query' => 'SELECT * FROM article t0'],
136+
['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 (?, ?, ?, ?, ?)'],
137+
['count' => 1, 'query' => 'SELECT * FROM tag t0_ WHERE t0_.id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'],
138+
]);
139+
}
140+
118141
/**
119142
* @param array<Article> $articles
120143
*/

tests/EntityPreloadBlogManyHasOneTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,28 @@ public function testManyHasOneWithPreload(DbalType $primaryKey): void
118118
]);
119119
}
120120

121+
#[DataProvider('providePrimaryKeyTypes')]
122+
public function testManyHasOneWithPreloadReadOnly(DbalType $primaryKey): void
123+
{
124+
$this->createDummyBlogData($primaryKey, categoryCount: 5, articleInEachCategoryCount: 5);
125+
126+
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
127+
$categories = $this->getEntityPreloader()->preload($articles, 'category', readOnly: true);
128+
129+
self::assertCount(5, $categories);
130+
131+
// Verify that preloaded categories are marked as read-only
132+
$unitOfWork = $this->getEntityManager()->getUnitOfWork();
133+
foreach ($categories as $category) {
134+
self::assertTrue($unitOfWork->isReadOnly($category), 'Category should be marked as read-only');
135+
}
136+
137+
self::assertAggregatedQueries([
138+
['count' => 1, 'query' => 'SELECT * FROM article t0'],
139+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.id IN (?, ?, ?, ?, ?)'],
140+
]);
141+
}
142+
121143
/**
122144
* @param array<Article> $articles
123145
*/

tests/EntityPreloadBlogOneHasManyTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,28 @@ public function testOneHasManyWithPreload(DbalType $primaryKey): void
139139
]);
140140
}
141141

142+
#[DataProvider('providePrimaryKeyTypes')]
143+
public function testOneHasManyWithPreloadReadOnly(DbalType $primaryKey): void
144+
{
145+
$this->createDummyBlogData($primaryKey, categoryCount: 5, articleInEachCategoryCount: 5);
146+
147+
$categories = $this->getEntityManager()->getRepository(Category::class)->findAll();
148+
$articles = $this->getEntityPreloader()->preload($categories, 'articles', readOnly: true);
149+
150+
self::assertCount(25, $articles);
151+
152+
// Verify that preloaded articles are marked as read-only
153+
$unitOfWork = $this->getEntityManager()->getUnitOfWork();
154+
foreach ($articles as $article) {
155+
self::assertTrue($unitOfWork->isReadOnly($article), 'Article should be marked as read-only');
156+
}
157+
158+
self::assertAggregatedQueries([
159+
['count' => 1, 'query' => 'SELECT * FROM category t0'],
160+
['count' => 1, 'query' => 'SELECT * FROM article a0_ WHERE a0_.category_id IN (?, ?, ?, ?, ?)'],
161+
]);
162+
}
163+
142164
/**
143165
* @param array<Category> $categories
144166
*/

0 commit comments

Comments
 (0)