Skip to content

Commit

Permalink
Merge pull request doctrine#8391 from beberlei/doctrineGH-1569-Subsel…
Browse files Browse the repository at this point in the history
…ectFetchMode

[doctrineGH-1569] Optimize eager fetch for collections to batch query
  • Loading branch information
greg0ire authored Nov 14, 2023
2 parents b41de2a + ec74c83 commit 1267f48
Show file tree
Hide file tree
Showing 10 changed files with 302 additions and 20 deletions.
17 changes: 17 additions & 0 deletions docs/en/reference/working-with-objects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,23 @@ and these associations are mapped as EAGER, they will automatically
be loaded together with the entity being queried and is thus
immediately available to your application.

Eager Loading can also be configured at runtime through
``AbstractQuery::setFetchMode`` in DQL or Native Queries.

Eager loading for many-to-one and one-to-one associations is using either a
LEFT JOIN or a second query for fetching the related entity eagerly.

Eager loading for many-to-one associations uses a second query to load
the collections for several entities at the same time.

When many-to-many, one-to-one or one-to-many associations are eagerly loaded,
then the global batch size configuration is used to avoid IN(?) queries with
too many arguments. The default batch size is 100 and can be changed with
``Configuration::setEagerFetchBatchSize()``.

For eagerly loaded Many-To-Many associations one query has to be made for each
collection.

By Lazy Loading
~~~~~~~~~~~~~~~

Expand Down
10 changes: 10 additions & 0 deletions lib/Doctrine/ORM/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -1144,4 +1144,14 @@ public function isRejectIdCollisionInIdentityMapEnabled(): bool
{
return $this->_attributes['rejectIdCollisionInIdentityMap'] ?? false;
}

public function setEagerFetchBatchSize(int $batchSize = 100): void
{
$this->_attributes['fetchModeSubselectBatchSize'] = $batchSize;
}

public function getEagerFetchBatchSize(): int
{
return $this->_attributes['fetchModeSubselectBatchSize'] ?? 100;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,7 @@ protected function getSelectColumnsSQL()
}

$isAssocToOneInverseSide = $assoc['type'] & ClassMetadata::TO_ONE && ! $assoc['isOwningSide'];
$isAssocFromOneEager = $assoc['type'] !== ClassMetadata::MANY_TO_MANY && $assoc['fetch'] === ClassMetadata::FETCH_EAGER;
$isAssocFromOneEager = $assoc['type'] & ClassMetadata::TO_ONE && $assoc['fetch'] === ClassMetadata::FETCH_EAGER;

if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) {
continue;
Expand Down
8 changes: 8 additions & 0 deletions lib/Doctrine/ORM/Query/QueryException.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ public static function iterateWithFetchJoinNotAllowed($assoc)
);
}

public static function eagerFetchJoinWithNotAllowed(string $sourceEntity, string $fieldName): QueryException
{
return new self(
'Associations with fetch-mode=EAGER may not be using WITH conditions in
"' . $sourceEntity . '#' . $fieldName . '".'
);
}

public static function iterateWithMixedResultNotAllowed(): QueryException
{
return new self('Iterating a query with mixed results (using scalars) is not supported.');
Expand Down
4 changes: 3 additions & 1 deletion lib/Doctrine/ORM/Query/SqlWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,9 @@ public function walkJoinAssociationDeclaration($joinAssociationDeclaration, $joi
}
}

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

// This condition is not checking ClassMetadata::MANY_TO_ONE, because by definition it cannot
// be the owning side and previously we ensured that $assoc is always the owning side of the associations.
Expand Down
118 changes: 106 additions & 12 deletions lib/Doctrine/ORM/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
use Throwable;
use UnexpectedValueException;

use function array_chunk;
use function array_combine;
use function array_diff_key;
use function array_filter;
Expand Down Expand Up @@ -314,6 +315,9 @@ class UnitOfWork implements PropertyChangedListener
*/
private $eagerLoadingEntities = [];

/** @var array<string, array<string, mixed>> */
private $eagerLoadingCollections = [];

/** @var bool */
protected $hasCache = false;

Expand Down Expand Up @@ -2749,6 +2753,7 @@ public function clear($entityName = null)
$this->pendingCollectionElementRemovals =
$this->visitedCollections =
$this->eagerLoadingEntities =
$this->eagerLoadingCollections =
$this->orphanRemovals = [];
} else {
Deprecation::triggerIfCalledFromOutside(
Expand Down Expand Up @@ -2938,6 +2943,10 @@ public function createEntity($className, array $data, &$hints = [])
continue;
}

if (! isset($hints['fetchMode'][$class->name][$field])) {
$hints['fetchMode'][$class->name][$field] = $assoc['fetch'];
}

$targetClass = $this->em->getClassMetadata($assoc['targetEntity']);

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

if (! isset($hints['fetchMode'][$class->name][$field])) {
$hints['fetchMode'][$class->name][$field] = $assoc['fetch'];
}

// Foreign key is set
// Check identity map first
// FIXME: Can break easily with composite keys if join column values are in
Expand Down Expand Up @@ -3098,9 +3103,13 @@ public function createEntity($className, array $data, &$hints = [])
$reflField = $class->reflFields[$field];
$reflField->setValue($entity, $pColl);

if ($assoc['fetch'] === ClassMetadata::FETCH_EAGER) {
$this->loadCollection($pColl);
$pColl->takeSnapshot();
if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
if ($assoc['type'] === ClassMetadata::ONE_TO_MANY) {
$this->scheduleCollectionForBatchLoading($pColl, $class);
} elseif ($assoc['type'] === ClassMetadata::MANY_TO_MANY) {
$this->loadCollection($pColl);
$pColl->takeSnapshot();
}
}

$this->originalEntityData[$oid][$field] = $pColl;
Expand All @@ -3117,7 +3126,7 @@ public function createEntity($className, array $data, &$hints = [])
/** @return void */
public function triggerEagerLoads()
{
if (! $this->eagerLoadingEntities) {
if (! $this->eagerLoadingEntities && ! $this->eagerLoadingCollections) {
return;
}

Expand All @@ -3130,11 +3139,69 @@ public function triggerEagerLoads()
continue;
}

$class = $this->em->getClassMetadata($entityName);
$class = $this->em->getClassMetadata($entityName);
$batches = array_chunk($ids, $this->em->getConfiguration()->getEagerFetchBatchSize());

$this->getEntityPersister($entityName)->loadAll(
array_combine($class->identifier, [array_values($ids)])
);
foreach ($batches as $batchedIds) {
$this->getEntityPersister($entityName)->loadAll(
array_combine($class->identifier, [$batchedIds])
);
}
}

$eagerLoadingCollections = $this->eagerLoadingCollections; // avoid recursion
$this->eagerLoadingCollections = [];

foreach ($eagerLoadingCollections as $group) {
$this->eagerLoadCollections($group['items'], $group['mapping']);
}
}

/**
* Load all data into the given collections, according to the specified mapping
*
* @param PersistentCollection[] $collections
* @param array<string, mixed> $mapping
* @psalm-param array{targetEntity: class-string, sourceEntity: class-string, mappedBy: string, indexBy: string|null} $mapping
*/
private function eagerLoadCollections(array $collections, array $mapping): void
{
$targetEntity = $mapping['targetEntity'];
$class = $this->em->getClassMetadata($mapping['sourceEntity']);
$mappedBy = $mapping['mappedBy'];

$batches = array_chunk($collections, $this->em->getConfiguration()->getEagerFetchBatchSize(), true);

foreach ($batches as $collectionBatch) {
$entities = [];

foreach ($collectionBatch as $collection) {
$entities[] = $collection->getOwner();
}

$found = $this->getEntityPersister($targetEntity)->loadAll([$mappedBy => $entities]);

$targetClass = $this->em->getClassMetadata($targetEntity);
$targetProperty = $targetClass->getReflectionProperty($mappedBy);

foreach ($found as $targetValue) {
$sourceEntity = $targetProperty->getValue($targetValue);

$id = $this->identifierFlattener->flattenIdentifier($class, $class->getIdentifierValues($sourceEntity));
$idHash = implode(' ', $id);

if (isset($mapping['indexBy'])) {
$indexByProperty = $targetClass->getReflectionProperty($mapping['indexBy']);
$collectionBatch[$idHash]->hydrateSet($indexByProperty->getValue($targetValue), $targetValue);
} else {
$collectionBatch[$idHash]->add($targetValue);
}
}
}

foreach ($collections as $association) {
$association->setInitialized(true);
$association->takeSnapshot();
}
}

Expand Down Expand Up @@ -3165,6 +3232,33 @@ public function loadCollection(PersistentCollection $collection)
$collection->setInitialized(true);
}

/**
* Schedule this collection for batch loading at the end of the UnitOfWork
*/
private function scheduleCollectionForBatchLoading(PersistentCollection $collection, ClassMetadata $sourceClass): void
{
$mapping = $collection->getMapping();
$name = $mapping['sourceEntity'] . '#' . $mapping['fieldName'];

if (! isset($this->eagerLoadingCollections[$name])) {
$this->eagerLoadingCollections[$name] = [
'items' => [],
'mapping' => $mapping,
];
}

$owner = $collection->getOwner();
assert($owner !== null);

$id = $this->identifierFlattener->flattenIdentifier(
$sourceClass,
$sourceClass->getIdentifierValues($owner)
);
$idHash = implode(' ', $id);

$this->eagerLoadingCollections[$name]['items'][$idHash] = $collection;
}

/**
* Gets the identity map of the UnitOfWork.
*
Expand Down
Loading

0 comments on commit 1267f48

Please sign in to comment.