Skip to content

Commit 7974a92

Browse files
committed
Merge remote-tracking branch 'derrabus/3.0.x' into 3.0.x
* derrabus/3.0.x: Deprecate annotation classes for named queries Fix typos Housekeeping: Revert change to AbstractExporter, not needed without subselect fetch. Address review comments. Explain internals of eager loading in a bit more detail and how its configured. 1:1 and M:1 associations also use fetch batch size configuration now. Add another testcase for DQL based fetch eager of collection. last violation hopefully Static analysis Housekeeping: phpcs Directly load many to many collections, batching not supported yet. fix tests. Avoid new fetch mode, use this strategy with fetch=EAGER for collections. Make sure to many assocatinos are also respecting AbstractQuery::setFetchMode Disallow use of fetch=SUBSELECT on to-one associations. Go through Persister API instead of indirectly through repository. Introduce configuration option for subselect batch size. Houskeeping: phpcs Disallow WITH keyword on fetch joined associatiosn via subselect. [GH-1569] Add new SUBSELECT fetch mode for OneToMany associations.
2 parents 1245933 + 56df970 commit 7974a92

File tree

12 files changed

+299
-19
lines changed

12 files changed

+299
-19
lines changed

UPGRADE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,15 @@ Use `toIterable()` instead.
666666

667667
# Upgrade to 2.17
668668

669+
## Deprecate annotations classes for named queries
670+
671+
The following classes have been deprecated:
672+
673+
* `Doctrine\ORM\Mapping\NamedNativeQueries`
674+
* `Doctrine\ORM\Mapping\NamedNativeQuery`
675+
* `Doctrine\ORM\Mapping\NamedQueries`
676+
* `Doctrine\ORM\Mapping\NamedQuery`
677+
669678
## Deprecate `Doctrine\ORM\Query\Exec\AbstractSqlExecutor::_sqlStatements`
670679

671680
Use `Doctrine\ORM\Query\Exec\AbstractSqlExecutor::sqlStatements` instead.

docs/en/reference/working-with-objects.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,23 @@ and these associations are mapped as EAGER, they will automatically
711711
be loaded together with the entity being queried and is thus
712712
immediately available to your application.
713713

714+
Eager Loading can also be configured at runtime through
715+
``AbstractQuery::setFetchMode`` in DQL or Native Queries.
716+
717+
Eager loading for many-to-one and one-to-one associations is using either a
718+
LEFT JOIN or a second query for fetching the related entity eagerly.
719+
720+
Eager loading for many-to-one associations uses a second query to load
721+
the collections for several entities at the same time.
722+
723+
When many-to-many, one-to-one or one-to-many associations are eagerly loaded,
724+
then the global batch size configuration is used to avoid IN(?) queries with
725+
too many arguments. The default batch size is 100 and can be changed with
726+
``Configuration::setEagerFetchBatchSize()``.
727+
728+
For eagerly loaded Many-To-Many associations one query has to be made for each
729+
collection.
730+
714731
By Lazy Loading
715732
~~~~~~~~~~~~~~~
716733

lib/Doctrine/ORM/Configuration.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,4 +636,14 @@ public function isRejectIdCollisionInIdentityMapEnabled(): bool
636636
{
637637
return true;
638638
}
639+
640+
public function setEagerFetchBatchSize(int $batchSize = 100): void
641+
{
642+
$this->attributes['fetchModeSubselectBatchSize'] = $batchSize;
643+
}
644+
645+
public function getEagerFetchBatchSize(): int
646+
{
647+
return $this->attributes['fetchModeSubselectBatchSize'] ?? 100;
648+
}
639649
}

lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1220,7 +1220,7 @@ protected function getSelectColumnsSQL(): string
12201220
}
12211221

12221222
$isAssocToOneInverseSide = $assoc->isToOne() && ! $assoc->isOwningSide();
1223-
$isAssocFromOneEager = ! $assoc->isManyToMany() && $assoc->fetch === ClassMetadata::FETCH_EAGER;
1223+
$isAssocFromOneEager = $assoc->isToOne() && $assoc->fetch === ClassMetadata::FETCH_EAGER;
12241224

12251225
if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) {
12261226
continue;

lib/Doctrine/ORM/Query/QueryException.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ public static function iterateWithFetchJoinNotAllowed(AssociationMapping $assoc)
117117
);
118118
}
119119

120+
public static function eagerFetchJoinWithNotAllowed(string $sourceEntity, string $fieldName): self
121+
{
122+
return new self(
123+
'Associations with fetch-mode=EAGER may not be using WITH conditions in
124+
"' . $sourceEntity . '#' . $fieldName . '".',
125+
);
126+
}
127+
120128
public static function iterateWithMixedResultNotAllowed(): self
121129
{
122130
return new self('Iterating a query with mixed results (using scalars) is not supported.');

lib/Doctrine/ORM/Query/SqlWalker.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,9 @@ public function walkJoinAssociationDeclaration(
894894
}
895895
}
896896

897-
$targetTableJoin = null;
897+
if ($relation->fetch === ClassMetadata::FETCH_EAGER && $condExpr !== null) {
898+
throw QueryException::eagerFetchJoinWithNotAllowed($assoc->sourceEntity, $assoc->fieldName);
899+
}
898900

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

lib/Doctrine/ORM/UnitOfWork.php

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Doctrine\ORM\Mapping\AssociationMapping;
3434
use Doctrine\ORM\Mapping\ClassMetadata;
3535
use Doctrine\ORM\Mapping\MappingException;
36+
use Doctrine\ORM\Mapping\ToManyInverseSideMapping;
3637
use Doctrine\ORM\Persisters\Collection\CollectionPersister;
3738
use Doctrine\ORM\Persisters\Collection\ManyToManyPersister;
3839
use Doctrine\ORM\Persisters\Collection\OneToManyPersister;
@@ -50,6 +51,7 @@
5051
use Throwable;
5152
use UnexpectedValueException;
5253

54+
use function array_chunk;
5355
use function array_combine;
5456
use function array_diff_key;
5557
use function array_filter;
@@ -292,6 +294,9 @@ class UnitOfWork implements PropertyChangedListener
292294
*/
293295
private array $eagerLoadingEntities = [];
294296

297+
/** @var array<string, array<string, mixed>> */
298+
private array $eagerLoadingCollections = [];
299+
295300
protected bool $hasCache = false;
296301

297302
/**
@@ -2218,6 +2223,7 @@ public function clear(): void
22182223
$this->pendingCollectionElementRemovals =
22192224
$this->visitedCollections =
22202225
$this->eagerLoadingEntities =
2226+
$this->eagerLoadingCollections =
22212227
$this->orphanRemovals = [];
22222228

22232229
if ($this->evm->hasListeners(Events::onClear)) {
@@ -2352,6 +2358,10 @@ public function createEntity(string $className, array $data, array &$hints = [])
23522358
continue;
23532359
}
23542360

2361+
if (! isset($hints['fetchMode'][$class->name][$field])) {
2362+
$hints['fetchMode'][$class->name][$field] = $assoc->fetch;
2363+
}
2364+
23552365
$targetClass = $this->em->getClassMetadata($assoc->targetEntity);
23562366

23572367
switch (true) {
@@ -2416,10 +2426,6 @@ public function createEntity(string $className, array $data, array &$hints = [])
24162426
break;
24172427
}
24182428

2419-
if (! isset($hints['fetchMode'][$class->name][$field])) {
2420-
$hints['fetchMode'][$class->name][$field] = $assoc->fetch;
2421-
}
2422-
24232429
// Foreign key is set
24242430
// Check identity map first
24252431
// FIXME: Can break easily with composite keys if join column values are in
@@ -2514,9 +2520,13 @@ public function createEntity(string $className, array $data, array &$hints = [])
25142520
$reflField = $class->reflFields[$field];
25152521
$reflField->setValue($entity, $pColl);
25162522

2517-
if ($assoc->fetch === ClassMetadata::FETCH_EAGER) {
2518-
$this->loadCollection($pColl);
2519-
$pColl->takeSnapshot();
2523+
if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
2524+
if ($assoc->isOneToMany()) {
2525+
$this->scheduleCollectionForBatchLoading($pColl, $class);
2526+
} elseif ($assoc->isManyToMany()) {
2527+
$this->loadCollection($pColl);
2528+
$pColl->takeSnapshot();
2529+
}
25202530
}
25212531

25222532
$this->originalEntityData[$oid][$field] = $pColl;
@@ -2532,7 +2542,7 @@ public function createEntity(string $className, array $data, array &$hints = [])
25322542

25332543
public function triggerEagerLoads(): void
25342544
{
2535-
if (! $this->eagerLoadingEntities) {
2545+
if (! $this->eagerLoadingEntities && ! $this->eagerLoadingCollections) {
25362546
return;
25372547
}
25382548

@@ -2545,11 +2555,69 @@ public function triggerEagerLoads(): void
25452555
continue;
25462556
}
25472557

2548-
$class = $this->em->getClassMetadata($entityName);
2558+
$class = $this->em->getClassMetadata($entityName);
2559+
$batches = array_chunk($ids, $this->em->getConfiguration()->getEagerFetchBatchSize());
25492560

2550-
$this->getEntityPersister($entityName)->loadAll(
2551-
array_combine($class->identifier, [array_values($ids)]),
2552-
);
2561+
foreach ($batches as $batchedIds) {
2562+
$this->getEntityPersister($entityName)->loadAll(
2563+
array_combine($class->identifier, [$batchedIds]),
2564+
);
2565+
}
2566+
}
2567+
2568+
$eagerLoadingCollections = $this->eagerLoadingCollections; // avoid recursion
2569+
$this->eagerLoadingCollections = [];
2570+
2571+
foreach ($eagerLoadingCollections as $group) {
2572+
$this->eagerLoadCollections($group['items'], $group['mapping']);
2573+
}
2574+
}
2575+
2576+
/**
2577+
* Load all data into the given collections, according to the specified mapping
2578+
*
2579+
* @param PersistentCollection[] $collections
2580+
*/
2581+
private function eagerLoadCollections(array $collections, ToManyInverseSideMapping $mapping): void
2582+
{
2583+
$targetEntity = $mapping->targetEntity;
2584+
$class = $this->em->getClassMetadata($mapping->sourceEntity);
2585+
$mappedBy = $mapping->mappedBy;
2586+
2587+
$batches = array_chunk($collections, $this->em->getConfiguration()->getEagerFetchBatchSize(), true);
2588+
2589+
foreach ($batches as $collectionBatch) {
2590+
$entities = [];
2591+
2592+
foreach ($collectionBatch as $collection) {
2593+
$entities[] = $collection->getOwner();
2594+
}
2595+
2596+
$found = $this->getEntityPersister($targetEntity)->loadAll([$mappedBy => $entities]);
2597+
2598+
$targetClass = $this->em->getClassMetadata($targetEntity);
2599+
$targetProperty = $targetClass->getReflectionProperty($mappedBy);
2600+
assert($targetProperty !== null);
2601+
2602+
foreach ($found as $targetValue) {
2603+
$sourceEntity = $targetProperty->getValue($targetValue);
2604+
2605+
$id = $this->identifierFlattener->flattenIdentifier($class, $class->getIdentifierValues($sourceEntity));
2606+
$idHash = implode(' ', $id);
2607+
2608+
if ($mapping->indexBy !== null) {
2609+
$indexByProperty = $targetClass->getReflectionProperty($mapping->indexBy);
2610+
assert($indexByProperty !== null);
2611+
$collectionBatch[$idHash]->hydrateSet($indexByProperty->getValue($targetValue), $targetValue);
2612+
} else {
2613+
$collectionBatch[$idHash]->add($targetValue);
2614+
}
2615+
}
2616+
}
2617+
2618+
foreach ($collections as $association) {
2619+
$association->setInitialized(true);
2620+
$association->takeSnapshot();
25532621
}
25542622
}
25552623

@@ -2576,6 +2644,33 @@ public function loadCollection(PersistentCollection $collection): void
25762644
$collection->setInitialized(true);
25772645
}
25782646

2647+
/**
2648+
* Schedule this collection for batch loading at the end of the UnitOfWork
2649+
*/
2650+
private function scheduleCollectionForBatchLoading(PersistentCollection $collection, ClassMetadata $sourceClass): void
2651+
{
2652+
$mapping = $collection->getMapping();
2653+
$name = $mapping['sourceEntity'] . '#' . $mapping['fieldName'];
2654+
2655+
if (! isset($this->eagerLoadingCollections[$name])) {
2656+
$this->eagerLoadingCollections[$name] = [
2657+
'items' => [],
2658+
'mapping' => $mapping,
2659+
];
2660+
}
2661+
2662+
$owner = $collection->getOwner();
2663+
assert($owner !== null);
2664+
2665+
$id = $this->identifierFlattener->flattenIdentifier(
2666+
$sourceClass,
2667+
$sourceClass->getIdentifierValues($owner),
2668+
);
2669+
$idHash = implode(' ', $id);
2670+
2671+
$this->eagerLoadingCollections[$name]['items'][$idHash] = $collection;
2672+
}
2673+
25792674
/**
25802675
* Gets the identity map of the UnitOfWork.
25812676
*

psalm.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
<referencedClass name="Doctrine\ORM\Exception\UnknownEntityNamespace"/>
3232
<referencedClass name="Doctrine\ORM\Mapping\Driver\AnnotationDriver"/>
3333
<referencedClass name="Doctrine\ORM\Mapping\Driver\YamlDriver"/>
34+
<referencedClass name="Doctrine\ORM\Mapping\NamedNativeQueries"/>
35+
<referencedClass name="Doctrine\ORM\Mapping\NamedNativeQuery"/>
36+
<referencedClass name="Doctrine\ORM\Mapping\NamedQueries"/>
37+
<referencedClass name="Doctrine\ORM\Mapping\NamedQuery"/>
3438
<referencedClass name="Doctrine\ORM\Query\AST\InExpression"/>
3539
<referencedClass name="Doctrine\ORM\Tools\Console\Command\ConvertDoctrine1SchemaCommand"/>
3640
<referencedClass name="Doctrine\ORM\Tools\Console\Command\ConvertMappingCommand"/>

0 commit comments

Comments
 (0)