Skip to content

Commit 1267f48

Browse files
authored
Merge pull request #8391 from beberlei/GH-1569-SubselectFetchMode
[GH-1569] Optimize eager fetch for collections to batch query
2 parents b41de2a + ec74c83 commit 1267f48

File tree

10 files changed

+302
-20
lines changed

10 files changed

+302
-20
lines changed

Diff for: docs/en/reference/working-with-objects.rst

+17
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,23 @@ and these associations are mapped as EAGER, they will automatically
782782
be loaded together with the entity being queried and is thus
783783
immediately available to your application.
784784

785+
Eager Loading can also be configured at runtime through
786+
``AbstractQuery::setFetchMode`` in DQL or Native Queries.
787+
788+
Eager loading for many-to-one and one-to-one associations is using either a
789+
LEFT JOIN or a second query for fetching the related entity eagerly.
790+
791+
Eager loading for many-to-one associations uses a second query to load
792+
the collections for several entities at the same time.
793+
794+
When many-to-many, one-to-one or one-to-many associations are eagerly loaded,
795+
then the global batch size configuration is used to avoid IN(?) queries with
796+
too many arguments. The default batch size is 100 and can be changed with
797+
``Configuration::setEagerFetchBatchSize()``.
798+
799+
For eagerly loaded Many-To-Many associations one query has to be made for each
800+
collection.
801+
785802
By Lazy Loading
786803
~~~~~~~~~~~~~~~
787804

Diff for: lib/Doctrine/ORM/Configuration.php

+10
Original file line numberDiff line numberDiff line change
@@ -1144,4 +1144,14 @@ public function isRejectIdCollisionInIdentityMapEnabled(): bool
11441144
{
11451145
return $this->_attributes['rejectIdCollisionInIdentityMap'] ?? false;
11461146
}
1147+
1148+
public function setEagerFetchBatchSize(int $batchSize = 100): void
1149+
{
1150+
$this->_attributes['fetchModeSubselectBatchSize'] = $batchSize;
1151+
}
1152+
1153+
public function getEagerFetchBatchSize(): int
1154+
{
1155+
return $this->_attributes['fetchModeSubselectBatchSize'] ?? 100;
1156+
}
11471157
}

Diff for: lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ protected function getSelectColumnsSQL()
12641264
}
12651265

12661266
$isAssocToOneInverseSide = $assoc['type'] & ClassMetadata::TO_ONE && ! $assoc['isOwningSide'];
1267-
$isAssocFromOneEager = $assoc['type'] !== ClassMetadata::MANY_TO_MANY && $assoc['fetch'] === ClassMetadata::FETCH_EAGER;
1267+
$isAssocFromOneEager = $assoc['type'] & ClassMetadata::TO_ONE && $assoc['fetch'] === ClassMetadata::FETCH_EAGER;
12681268

12691269
if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) {
12701270
continue;

Diff for: lib/Doctrine/ORM/Query/QueryException.php

+8
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ public static function iterateWithFetchJoinNotAllowed($assoc)
204204
);
205205
}
206206

207+
public static function eagerFetchJoinWithNotAllowed(string $sourceEntity, string $fieldName): QueryException
208+
{
209+
return new self(
210+
'Associations with fetch-mode=EAGER may not be using WITH conditions in
211+
"' . $sourceEntity . '#' . $fieldName . '".'
212+
);
213+
}
214+
207215
public static function iterateWithMixedResultNotAllowed(): QueryException
208216
{
209217
return new self('Iterating a query with mixed results (using scalars) is not supported.');

Diff for: lib/Doctrine/ORM/Query/SqlWalker.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,9 @@ public function walkJoinAssociationDeclaration($joinAssociationDeclaration, $joi
10471047
}
10481048
}
10491049

1050-
$targetTableJoin = null;
1050+
if ($relation['fetch'] === ClassMetadata::FETCH_EAGER && $condExpr !== null) {
1051+
throw QueryException::eagerFetchJoinWithNotAllowed($assoc['sourceEntity'], $assoc['fieldName']);
1052+
}
10511053

10521054
// This condition is not checking ClassMetadata::MANY_TO_ONE, because by definition it cannot
10531055
// be the owning side and previously we ensured that $assoc is always the owning side of the associations.

Diff for: lib/Doctrine/ORM/UnitOfWork.php

+106-12
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
use Throwable;
5252
use UnexpectedValueException;
5353

54+
use function array_chunk;
5455
use function array_combine;
5556
use function array_diff_key;
5657
use function array_filter;
@@ -314,6 +315,9 @@ class UnitOfWork implements PropertyChangedListener
314315
*/
315316
private $eagerLoadingEntities = [];
316317

318+
/** @var array<string, array<string, mixed>> */
319+
private $eagerLoadingCollections = [];
320+
317321
/** @var bool */
318322
protected $hasCache = false;
319323

@@ -2749,6 +2753,7 @@ public function clear($entityName = null)
27492753
$this->pendingCollectionElementRemovals =
27502754
$this->visitedCollections =
27512755
$this->eagerLoadingEntities =
2756+
$this->eagerLoadingCollections =
27522757
$this->orphanRemovals = [];
27532758
} else {
27542759
Deprecation::triggerIfCalledFromOutside(
@@ -2938,6 +2943,10 @@ public function createEntity($className, array $data, &$hints = [])
29382943
continue;
29392944
}
29402945

2946+
if (! isset($hints['fetchMode'][$class->name][$field])) {
2947+
$hints['fetchMode'][$class->name][$field] = $assoc['fetch'];
2948+
}
2949+
29412950
$targetClass = $this->em->getClassMetadata($assoc['targetEntity']);
29422951

29432952
switch (true) {
@@ -3001,10 +3010,6 @@ public function createEntity($className, array $data, &$hints = [])
30013010
break;
30023011
}
30033012

3004-
if (! isset($hints['fetchMode'][$class->name][$field])) {
3005-
$hints['fetchMode'][$class->name][$field] = $assoc['fetch'];
3006-
}
3007-
30083013
// Foreign key is set
30093014
// Check identity map first
30103015
// FIXME: Can break easily with composite keys if join column values are in
@@ -3098,9 +3103,13 @@ public function createEntity($className, array $data, &$hints = [])
30983103
$reflField = $class->reflFields[$field];
30993104
$reflField->setValue($entity, $pColl);
31003105

3101-
if ($assoc['fetch'] === ClassMetadata::FETCH_EAGER) {
3102-
$this->loadCollection($pColl);
3103-
$pColl->takeSnapshot();
3106+
if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
3107+
if ($assoc['type'] === ClassMetadata::ONE_TO_MANY) {
3108+
$this->scheduleCollectionForBatchLoading($pColl, $class);
3109+
} elseif ($assoc['type'] === ClassMetadata::MANY_TO_MANY) {
3110+
$this->loadCollection($pColl);
3111+
$pColl->takeSnapshot();
3112+
}
31043113
}
31053114

31063115
$this->originalEntityData[$oid][$field] = $pColl;
@@ -3117,7 +3126,7 @@ public function createEntity($className, array $data, &$hints = [])
31173126
/** @return void */
31183127
public function triggerEagerLoads()
31193128
{
3120-
if (! $this->eagerLoadingEntities) {
3129+
if (! $this->eagerLoadingEntities && ! $this->eagerLoadingCollections) {
31213130
return;
31223131
}
31233132

@@ -3130,11 +3139,69 @@ public function triggerEagerLoads()
31303139
continue;
31313140
}
31323141

3133-
$class = $this->em->getClassMetadata($entityName);
3142+
$class = $this->em->getClassMetadata($entityName);
3143+
$batches = array_chunk($ids, $this->em->getConfiguration()->getEagerFetchBatchSize());
31343144

3135-
$this->getEntityPersister($entityName)->loadAll(
3136-
array_combine($class->identifier, [array_values($ids)])
3137-
);
3145+
foreach ($batches as $batchedIds) {
3146+
$this->getEntityPersister($entityName)->loadAll(
3147+
array_combine($class->identifier, [$batchedIds])
3148+
);
3149+
}
3150+
}
3151+
3152+
$eagerLoadingCollections = $this->eagerLoadingCollections; // avoid recursion
3153+
$this->eagerLoadingCollections = [];
3154+
3155+
foreach ($eagerLoadingCollections as $group) {
3156+
$this->eagerLoadCollections($group['items'], $group['mapping']);
3157+
}
3158+
}
3159+
3160+
/**
3161+
* Load all data into the given collections, according to the specified mapping
3162+
*
3163+
* @param PersistentCollection[] $collections
3164+
* @param array<string, mixed> $mapping
3165+
* @psalm-param array{targetEntity: class-string, sourceEntity: class-string, mappedBy: string, indexBy: string|null} $mapping
3166+
*/
3167+
private function eagerLoadCollections(array $collections, array $mapping): void
3168+
{
3169+
$targetEntity = $mapping['targetEntity'];
3170+
$class = $this->em->getClassMetadata($mapping['sourceEntity']);
3171+
$mappedBy = $mapping['mappedBy'];
3172+
3173+
$batches = array_chunk($collections, $this->em->getConfiguration()->getEagerFetchBatchSize(), true);
3174+
3175+
foreach ($batches as $collectionBatch) {
3176+
$entities = [];
3177+
3178+
foreach ($collectionBatch as $collection) {
3179+
$entities[] = $collection->getOwner();
3180+
}
3181+
3182+
$found = $this->getEntityPersister($targetEntity)->loadAll([$mappedBy => $entities]);
3183+
3184+
$targetClass = $this->em->getClassMetadata($targetEntity);
3185+
$targetProperty = $targetClass->getReflectionProperty($mappedBy);
3186+
3187+
foreach ($found as $targetValue) {
3188+
$sourceEntity = $targetProperty->getValue($targetValue);
3189+
3190+
$id = $this->identifierFlattener->flattenIdentifier($class, $class->getIdentifierValues($sourceEntity));
3191+
$idHash = implode(' ', $id);
3192+
3193+
if (isset($mapping['indexBy'])) {
3194+
$indexByProperty = $targetClass->getReflectionProperty($mapping['indexBy']);
3195+
$collectionBatch[$idHash]->hydrateSet($indexByProperty->getValue($targetValue), $targetValue);
3196+
} else {
3197+
$collectionBatch[$idHash]->add($targetValue);
3198+
}
3199+
}
3200+
}
3201+
3202+
foreach ($collections as $association) {
3203+
$association->setInitialized(true);
3204+
$association->takeSnapshot();
31383205
}
31393206
}
31403207

@@ -3165,6 +3232,33 @@ public function loadCollection(PersistentCollection $collection)
31653232
$collection->setInitialized(true);
31663233
}
31673234

3235+
/**
3236+
* Schedule this collection for batch loading at the end of the UnitOfWork
3237+
*/
3238+
private function scheduleCollectionForBatchLoading(PersistentCollection $collection, ClassMetadata $sourceClass): void
3239+
{
3240+
$mapping = $collection->getMapping();
3241+
$name = $mapping['sourceEntity'] . '#' . $mapping['fieldName'];
3242+
3243+
if (! isset($this->eagerLoadingCollections[$name])) {
3244+
$this->eagerLoadingCollections[$name] = [
3245+
'items' => [],
3246+
'mapping' => $mapping,
3247+
];
3248+
}
3249+
3250+
$owner = $collection->getOwner();
3251+
assert($owner !== null);
3252+
3253+
$id = $this->identifierFlattener->flattenIdentifier(
3254+
$sourceClass,
3255+
$sourceClass->getIdentifierValues($owner)
3256+
);
3257+
$idHash = implode(' ', $id);
3258+
3259+
$this->eagerLoadingCollections[$name]['items'][$idHash] = $collection;
3260+
}
3261+
31683262
/**
31693263
* Gets the identity map of the UnitOfWork.
31703264
*

0 commit comments

Comments
 (0)