summaryrefslogtreecommitdiff
path: root/vendor/doctrine/orm/src/UnitOfWork.php
diff options
context:
space:
mode:
authorpolo <ordipolo@gmx.fr>2024-08-13 23:45:21 +0200
committerpolo <ordipolo@gmx.fr>2024-08-13 23:45:21 +0200
commitbf6655a534a6775d30cafa67bd801276bda1d98d (patch)
treec6381e3f6c81c33eab72508f410b165ba05f7e9c /vendor/doctrine/orm/src/UnitOfWork.php
parent94d67a4b51f8e62e7d518cce26a526ae1ec48278 (diff)
downloadAppliGestionPHP-bf6655a534a6775d30cafa67bd801276bda1d98d.zip
VERSION 0.2 doctrine ORM et entités
Diffstat (limited to 'vendor/doctrine/orm/src/UnitOfWork.php')
-rw-r--r--vendor/doctrine/orm/src/UnitOfWork.php3252
1 files changed, 3252 insertions, 0 deletions
diff --git a/vendor/doctrine/orm/src/UnitOfWork.php b/vendor/doctrine/orm/src/UnitOfWork.php
new file mode 100644
index 0000000..26f17d2
--- /dev/null
+++ b/vendor/doctrine/orm/src/UnitOfWork.php
@@ -0,0 +1,3252 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM;
6
7use BackedEnum;
8use DateTimeInterface;
9use Doctrine\Common\Collections\ArrayCollection;
10use Doctrine\Common\Collections\Collection;
11use Doctrine\Common\EventManager;
12use Doctrine\DBAL;
13use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
14use Doctrine\DBAL\LockMode;
15use Doctrine\ORM\Cache\Persister\CachedPersister;
16use Doctrine\ORM\Event\ListenersInvoker;
17use Doctrine\ORM\Event\OnClearEventArgs;
18use Doctrine\ORM\Event\OnFlushEventArgs;
19use Doctrine\ORM\Event\PostFlushEventArgs;
20use Doctrine\ORM\Event\PostPersistEventArgs;
21use Doctrine\ORM\Event\PostRemoveEventArgs;
22use Doctrine\ORM\Event\PostUpdateEventArgs;
23use Doctrine\ORM\Event\PreFlushEventArgs;
24use Doctrine\ORM\Event\PrePersistEventArgs;
25use Doctrine\ORM\Event\PreRemoveEventArgs;
26use Doctrine\ORM\Event\PreUpdateEventArgs;
27use Doctrine\ORM\Exception\EntityIdentityCollisionException;
28use Doctrine\ORM\Exception\ORMException;
29use Doctrine\ORM\Exception\UnexpectedAssociationValue;
30use Doctrine\ORM\Id\AssignedGenerator;
31use Doctrine\ORM\Internal\HydrationCompleteHandler;
32use Doctrine\ORM\Internal\StronglyConnectedComponents;
33use Doctrine\ORM\Internal\TopologicalSort;
34use Doctrine\ORM\Mapping\AssociationMapping;
35use Doctrine\ORM\Mapping\ClassMetadata;
36use Doctrine\ORM\Mapping\MappingException;
37use Doctrine\ORM\Mapping\ToManyInverseSideMapping;
38use Doctrine\ORM\Persisters\Collection\CollectionPersister;
39use Doctrine\ORM\Persisters\Collection\ManyToManyPersister;
40use Doctrine\ORM\Persisters\Collection\OneToManyPersister;
41use Doctrine\ORM\Persisters\Entity\BasicEntityPersister;
42use Doctrine\ORM\Persisters\Entity\EntityPersister;
43use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister;
44use Doctrine\ORM\Persisters\Entity\SingleTablePersister;
45use Doctrine\ORM\Proxy\InternalProxy;
46use Doctrine\ORM\Utility\IdentifierFlattener;
47use Doctrine\Persistence\PropertyChangedListener;
48use Exception;
49use InvalidArgumentException;
50use RuntimeException;
51use Stringable;
52use Throwable;
53use UnexpectedValueException;
54
55use function array_chunk;
56use function array_combine;
57use function array_diff_key;
58use function array_filter;
59use function array_key_exists;
60use function array_map;
61use function array_sum;
62use function array_values;
63use function assert;
64use function current;
65use function get_debug_type;
66use function implode;
67use function in_array;
68use function is_array;
69use function is_object;
70use function reset;
71use function spl_object_id;
72use function sprintf;
73use function strtolower;
74
75/**
76 * The UnitOfWork is responsible for tracking changes to objects during an
77 * "object-level" transaction and for writing out changes to the database
78 * in the correct order.
79 *
80 * Internal note: This class contains highly performance-sensitive code.
81 */
82class UnitOfWork implements PropertyChangedListener
83{
84 /**
85 * An entity is in MANAGED state when its persistence is managed by an EntityManager.
86 */
87 public const STATE_MANAGED = 1;
88
89 /**
90 * An entity is new if it has just been instantiated (i.e. using the "new" operator)
91 * and is not (yet) managed by an EntityManager.
92 */
93 public const STATE_NEW = 2;
94
95 /**
96 * A detached entity is an instance with persistent state and identity that is not
97 * (or no longer) associated with an EntityManager (and a UnitOfWork).
98 */
99 public const STATE_DETACHED = 3;
100
101 /**
102 * A removed entity instance is an instance with a persistent identity,
103 * associated with an EntityManager, whose persistent state will be deleted
104 * on commit.
105 */
106 public const STATE_REMOVED = 4;
107
108 /**
109 * Hint used to collect all primary keys of associated entities during hydration
110 * and execute it in a dedicated query afterwards
111 *
112 * @see https://www.doctrine-project.org/projects/doctrine-orm/en/stable/reference/dql-doctrine-query-language.html#temporarily-change-fetch-mode-in-dql
113 */
114 public const HINT_DEFEREAGERLOAD = 'deferEagerLoad';
115
116 /**
117 * The identity map that holds references to all managed entities that have
118 * an identity. The entities are grouped by their class name.
119 * Since all classes in a hierarchy must share the same identifier set,
120 * we always take the root class name of the hierarchy.
121 *
122 * @psalm-var array<class-string, array<string, object>>
123 */
124 private array $identityMap = [];
125
126 /**
127 * Map of all identifiers of managed entities.
128 * Keys are object ids (spl_object_id).
129 *
130 * @psalm-var array<int, array<string, mixed>>
131 */
132 private array $entityIdentifiers = [];
133
134 /**
135 * Map of the original entity data of managed entities.
136 * Keys are object ids (spl_object_id). This is used for calculating changesets
137 * at commit time.
138 *
139 * Internal note: Note that PHPs "copy-on-write" behavior helps a lot with memory usage.
140 * A value will only really be copied if the value in the entity is modified
141 * by the user.
142 *
143 * @psalm-var array<int, array<string, mixed>>
144 */
145 private array $originalEntityData = [];
146
147 /**
148 * Map of entity changes. Keys are object ids (spl_object_id).
149 * Filled at the beginning of a commit of the UnitOfWork and cleaned at the end.
150 *
151 * @psalm-var array<int, array<string, array{mixed, mixed}>>
152 */
153 private array $entityChangeSets = [];
154
155 /**
156 * The (cached) states of any known entities.
157 * Keys are object ids (spl_object_id).
158 *
159 * @psalm-var array<int, self::STATE_*>
160 */
161 private array $entityStates = [];
162
163 /**
164 * Map of entities that are scheduled for dirty checking at commit time.
165 * This is only used for entities with a change tracking policy of DEFERRED_EXPLICIT.
166 * Keys are object ids (spl_object_id).
167 *
168 * @psalm-var array<class-string, array<int, mixed>>
169 */
170 private array $scheduledForSynchronization = [];
171
172 /**
173 * A list of all pending entity insertions.
174 *
175 * @psalm-var array<int, object>
176 */
177 private array $entityInsertions = [];
178
179 /**
180 * A list of all pending entity updates.
181 *
182 * @psalm-var array<int, object>
183 */
184 private array $entityUpdates = [];
185
186 /**
187 * Any pending extra updates that have been scheduled by persisters.
188 *
189 * @psalm-var array<int, array{object, array<string, array{mixed, mixed}>}>
190 */
191 private array $extraUpdates = [];
192
193 /**
194 * A list of all pending entity deletions.
195 *
196 * @psalm-var array<int, object>
197 */
198 private array $entityDeletions = [];
199
200 /**
201 * New entities that were discovered through relationships that were not
202 * marked as cascade-persist. During flush, this array is populated and
203 * then pruned of any entities that were discovered through a valid
204 * cascade-persist path. (Leftovers cause an error.)
205 *
206 * Keys are OIDs, payload is a two-item array describing the association
207 * and the entity.
208 *
209 * @var array<int, array{AssociationMapping, object}> indexed by respective object spl_object_id()
210 */
211 private array $nonCascadedNewDetectedEntities = [];
212
213 /**
214 * All pending collection deletions.
215 *
216 * @psalm-var array<int, PersistentCollection<array-key, object>>
217 */
218 private array $collectionDeletions = [];
219
220 /**
221 * All pending collection updates.
222 *
223 * @psalm-var array<int, PersistentCollection<array-key, object>>
224 */
225 private array $collectionUpdates = [];
226
227 /**
228 * List of collections visited during changeset calculation on a commit-phase of a UnitOfWork.
229 * At the end of the UnitOfWork all these collections will make new snapshots
230 * of their data.
231 *
232 * @psalm-var array<int, PersistentCollection<array-key, object>>
233 */
234 private array $visitedCollections = [];
235
236 /**
237 * List of collections visited during the changeset calculation that contain to-be-removed
238 * entities and need to have keys removed post commit.
239 *
240 * Indexed by Collection object ID, which also serves as the key in self::$visitedCollections;
241 * values are the key names that need to be removed.
242 *
243 * @psalm-var array<int, array<array-key, true>>
244 */
245 private array $pendingCollectionElementRemovals = [];
246
247 /**
248 * The entity persister instances used to persist entity instances.
249 *
250 * @psalm-var array<string, EntityPersister>
251 */
252 private array $persisters = [];
253
254 /**
255 * The collection persister instances used to persist collections.
256 *
257 * @psalm-var array<array-key, CollectionPersister>
258 */
259 private array $collectionPersisters = [];
260
261 /**
262 * The EventManager used for dispatching events.
263 */
264 private readonly EventManager $evm;
265
266 /**
267 * The ListenersInvoker used for dispatching events.
268 */
269 private readonly ListenersInvoker $listenersInvoker;
270
271 /**
272 * The IdentifierFlattener used for manipulating identifiers
273 */
274 private readonly IdentifierFlattener $identifierFlattener;
275
276 /**
277 * Orphaned entities that are scheduled for removal.
278 *
279 * @psalm-var array<int, object>
280 */
281 private array $orphanRemovals = [];
282
283 /**
284 * Read-Only objects are never evaluated
285 *
286 * @var array<int, true>
287 */
288 private array $readOnlyObjects = [];
289
290 /**
291 * Map of Entity Class-Names and corresponding IDs that should eager loaded when requested.
292 *
293 * @psalm-var array<class-string, array<string, mixed>>
294 */
295 private array $eagerLoadingEntities = [];
296
297 /** @var array<string, array<string, mixed>> */
298 private array $eagerLoadingCollections = [];
299
300 protected bool $hasCache = false;
301
302 /**
303 * Helper for handling completion of hydration
304 */
305 private readonly HydrationCompleteHandler $hydrationCompleteHandler;
306
307 /**
308 * Initializes a new UnitOfWork instance, bound to the given EntityManager.
309 *
310 * @param EntityManagerInterface $em The EntityManager that "owns" this UnitOfWork instance.
311 */
312 public function __construct(
313 private readonly EntityManagerInterface $em,
314 ) {
315 $this->evm = $em->getEventManager();
316 $this->listenersInvoker = new ListenersInvoker($em);
317 $this->hasCache = $em->getConfiguration()->isSecondLevelCacheEnabled();
318 $this->identifierFlattener = new IdentifierFlattener($this, $em->getMetadataFactory());
319 $this->hydrationCompleteHandler = new HydrationCompleteHandler($this->listenersInvoker, $em);
320 }
321
322 /**
323 * Commits the UnitOfWork, executing all operations that have been postponed
324 * up to this point. The state of all managed entities will be synchronized with
325 * the database.
326 *
327 * The operations are executed in the following order:
328 *
329 * 1) All entity insertions
330 * 2) All entity updates
331 * 3) All collection deletions
332 * 4) All collection updates
333 * 5) All entity deletions
334 *
335 * @throws Exception
336 */
337 public function commit(): void
338 {
339 $connection = $this->em->getConnection();
340
341 if ($connection instanceof PrimaryReadReplicaConnection) {
342 $connection->ensureConnectedToPrimary();
343 }
344
345 // Raise preFlush
346 if ($this->evm->hasListeners(Events::preFlush)) {
347 $this->evm->dispatchEvent(Events::preFlush, new PreFlushEventArgs($this->em));
348 }
349
350 // Compute changes done since last commit.
351 $this->computeChangeSets();
352
353 if (
354 ! ($this->entityInsertions ||
355 $this->entityDeletions ||
356 $this->entityUpdates ||
357 $this->collectionUpdates ||
358 $this->collectionDeletions ||
359 $this->orphanRemovals)
360 ) {
361 $this->dispatchOnFlushEvent();
362 $this->dispatchPostFlushEvent();
363
364 $this->postCommitCleanup();
365
366 return; // Nothing to do.
367 }
368
369 $this->assertThatThereAreNoUnintentionallyNonPersistedAssociations();
370
371 if ($this->orphanRemovals) {
372 foreach ($this->orphanRemovals as $orphan) {
373 $this->remove($orphan);
374 }
375 }
376
377 $this->dispatchOnFlushEvent();
378
379 $conn = $this->em->getConnection();
380 $conn->beginTransaction();
381
382 try {
383 // Collection deletions (deletions of complete collections)
384 foreach ($this->collectionDeletions as $collectionToDelete) {
385 // Deferred explicit tracked collections can be removed only when owning relation was persisted
386 $owner = $collectionToDelete->getOwner();
387
388 if ($this->em->getClassMetadata($owner::class)->isChangeTrackingDeferredImplicit() || $this->isScheduledForDirtyCheck($owner)) {
389 $this->getCollectionPersister($collectionToDelete->getMapping())->delete($collectionToDelete);
390 }
391 }
392
393 if ($this->entityInsertions) {
394 // Perform entity insertions first, so that all new entities have their rows in the database
395 // and can be referred to by foreign keys. The commit order only needs to take new entities
396 // into account (new entities referring to other new entities), since all other types (entities
397 // with updates or scheduled deletions) are currently not a problem, since they are already
398 // in the database.
399 $this->executeInserts();
400 }
401
402 if ($this->entityUpdates) {
403 // Updates do not need to follow a particular order
404 $this->executeUpdates();
405 }
406
407 // Extra updates that were requested by persisters.
408 // This may include foreign keys that could not be set when an entity was inserted,
409 // which may happen in the case of circular foreign key relationships.
410 if ($this->extraUpdates) {
411 $this->executeExtraUpdates();
412 }
413
414 // Collection updates (deleteRows, updateRows, insertRows)
415 // No particular order is necessary, since all entities themselves are already
416 // in the database
417 foreach ($this->collectionUpdates as $collectionToUpdate) {
418 $this->getCollectionPersister($collectionToUpdate->getMapping())->update($collectionToUpdate);
419 }
420
421 // Entity deletions come last. Their order only needs to take care of other deletions
422 // (first delete entities depending upon others, before deleting depended-upon entities).
423 if ($this->entityDeletions) {
424 $this->executeDeletions();
425 }
426
427 $commitFailed = false;
428 try {
429 if ($conn->commit() === false) {
430 $commitFailed = true;
431 }
432 } catch (DBAL\Exception $e) {
433 $commitFailed = true;
434 }
435
436 if ($commitFailed) {
437 throw new OptimisticLockException('Commit failed', null, $e ?? null);
438 }
439 } catch (Throwable $e) {
440 $this->em->close();
441
442 if ($conn->isTransactionActive()) {
443 $conn->rollBack();
444 }
445
446 $this->afterTransactionRolledBack();
447
448 throw $e;
449 }
450
451 $this->afterTransactionComplete();
452
453 // Unset removed entities from collections, and take new snapshots from
454 // all visited collections.
455 foreach ($this->visitedCollections as $coid => $coll) {
456 if (isset($this->pendingCollectionElementRemovals[$coid])) {
457 foreach ($this->pendingCollectionElementRemovals[$coid] as $key => $valueIgnored) {
458 unset($coll[$key]);
459 }
460 }
461
462 $coll->takeSnapshot();
463 }
464
465 $this->dispatchPostFlushEvent();
466
467 $this->postCommitCleanup();
468 }
469
470 private function postCommitCleanup(): void
471 {
472 $this->entityInsertions =
473 $this->entityUpdates =
474 $this->entityDeletions =
475 $this->extraUpdates =
476 $this->collectionUpdates =
477 $this->nonCascadedNewDetectedEntities =
478 $this->collectionDeletions =
479 $this->pendingCollectionElementRemovals =
480 $this->visitedCollections =
481 $this->orphanRemovals =
482 $this->entityChangeSets =
483 $this->scheduledForSynchronization = [];
484 }
485
486 /**
487 * Computes the changesets of all entities scheduled for insertion.
488 */
489 private function computeScheduleInsertsChangeSets(): void
490 {
491 foreach ($this->entityInsertions as $entity) {
492 $class = $this->em->getClassMetadata($entity::class);
493
494 $this->computeChangeSet($class, $entity);
495 }
496 }
497
498 /**
499 * Executes any extra updates that have been scheduled.
500 */
501 private function executeExtraUpdates(): void
502 {
503 foreach ($this->extraUpdates as $oid => $update) {
504 [$entity, $changeset] = $update;
505
506 $this->entityChangeSets[$oid] = $changeset;
507 $this->getEntityPersister($entity::class)->update($entity);
508 }
509
510 $this->extraUpdates = [];
511 }
512
513 /**
514 * Gets the changeset for an entity.
515 *
516 * @return mixed[][]
517 * @psalm-return array<string, array{mixed, mixed}|PersistentCollection>
518 */
519 public function & getEntityChangeSet(object $entity): array
520 {
521 $oid = spl_object_id($entity);
522 $data = [];
523
524 if (! isset($this->entityChangeSets[$oid])) {
525 return $data;
526 }
527
528 return $this->entityChangeSets[$oid];
529 }
530
531 /**
532 * Computes the changes that happened to a single entity.
533 *
534 * Modifies/populates the following properties:
535 *
536 * {@link _originalEntityData}
537 * If the entity is NEW or MANAGED but not yet fully persisted (only has an id)
538 * then it was not fetched from the database and therefore we have no original
539 * entity data yet. All of the current entity data is stored as the original entity data.
540 *
541 * {@link _entityChangeSets}
542 * The changes detected on all properties of the entity are stored there.
543 * A change is a tuple array where the first entry is the old value and the second
544 * entry is the new value of the property. Changesets are used by persisters
545 * to INSERT/UPDATE the persistent entity state.
546 *
547 * {@link _entityUpdates}
548 * If the entity is already fully MANAGED (has been fetched from the database before)
549 * and any changes to its properties are detected, then a reference to the entity is stored
550 * there to mark it for an update.
551 *
552 * {@link _collectionDeletions}
553 * If a PersistentCollection has been de-referenced in a fully MANAGED entity,
554 * then this collection is marked for deletion.
555 *
556 * @param ClassMetadata $class The class descriptor of the entity.
557 * @param object $entity The entity for which to compute the changes.
558 * @psalm-param ClassMetadata<T> $class
559 * @psalm-param T $entity
560 *
561 * @template T of object
562 *
563 * @ignore
564 */
565 public function computeChangeSet(ClassMetadata $class, object $entity): void
566 {
567 $oid = spl_object_id($entity);
568
569 if (isset($this->readOnlyObjects[$oid])) {
570 return;
571 }
572
573 if (! $class->isInheritanceTypeNone()) {
574 $class = $this->em->getClassMetadata($entity::class);
575 }
576
577 $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::preFlush) & ~ListenersInvoker::INVOKE_MANAGER;
578
579 if ($invoke !== ListenersInvoker::INVOKE_NONE) {
580 $this->listenersInvoker->invoke($class, Events::preFlush, $entity, new PreFlushEventArgs($this->em), $invoke);
581 }
582
583 $actualData = [];
584
585 foreach ($class->reflFields as $name => $refProp) {
586 $value = $refProp->getValue($entity);
587
588 if ($class->isCollectionValuedAssociation($name) && $value !== null) {
589 if ($value instanceof PersistentCollection) {
590 if ($value->getOwner() === $entity) {
591 $actualData[$name] = $value;
592 continue;
593 }
594
595 $value = new ArrayCollection($value->getValues());
596 }
597
598 // If $value is not a Collection then use an ArrayCollection.
599 if (! $value instanceof Collection) {
600 $value = new ArrayCollection($value);
601 }
602
603 $assoc = $class->associationMappings[$name];
604 assert($assoc->isToMany());
605
606 // Inject PersistentCollection
607 $value = new PersistentCollection(
608 $this->em,
609 $this->em->getClassMetadata($assoc->targetEntity),
610 $value,
611 );
612 $value->setOwner($entity, $assoc);
613 $value->setDirty(! $value->isEmpty());
614
615 $refProp->setValue($entity, $value);
616
617 $actualData[$name] = $value;
618
619 continue;
620 }
621
622 if (( ! $class->isIdentifier($name) || ! $class->isIdGeneratorIdentity()) && ($name !== $class->versionField)) {
623 $actualData[$name] = $value;
624 }
625 }
626
627 if (! isset($this->originalEntityData[$oid])) {
628 // Entity is either NEW or MANAGED but not yet fully persisted (only has an id).
629 // These result in an INSERT.
630 $this->originalEntityData[$oid] = $actualData;
631 $changeSet = [];
632
633 foreach ($actualData as $propName => $actualValue) {
634 if (! isset($class->associationMappings[$propName])) {
635 $changeSet[$propName] = [null, $actualValue];
636
637 continue;
638 }
639
640 $assoc = $class->associationMappings[$propName];
641
642 if ($assoc->isToOneOwningSide()) {
643 $changeSet[$propName] = [null, $actualValue];
644 }
645 }
646
647 $this->entityChangeSets[$oid] = $changeSet;
648 } else {
649 // Entity is "fully" MANAGED: it was already fully persisted before
650 // and we have a copy of the original data
651 $originalData = $this->originalEntityData[$oid];
652 $changeSet = [];
653
654 foreach ($actualData as $propName => $actualValue) {
655 // skip field, its a partially omitted one!
656 if (! (isset($originalData[$propName]) || array_key_exists($propName, $originalData))) {
657 continue;
658 }
659
660 $orgValue = $originalData[$propName];
661
662 if (! empty($class->fieldMappings[$propName]->enumType)) {
663 if (is_array($orgValue)) {
664 foreach ($orgValue as $id => $val) {
665 if ($val instanceof BackedEnum) {
666 $orgValue[$id] = $val->value;
667 }
668 }
669 } else {
670 if ($orgValue instanceof BackedEnum) {
671 $orgValue = $orgValue->value;
672 }
673 }
674 }
675
676 // skip if value haven't changed
677 if ($orgValue === $actualValue) {
678 continue;
679 }
680
681 // if regular field
682 if (! isset($class->associationMappings[$propName])) {
683 $changeSet[$propName] = [$orgValue, $actualValue];
684
685 continue;
686 }
687
688 $assoc = $class->associationMappings[$propName];
689
690 // Persistent collection was exchanged with the "originally"
691 // created one. This can only mean it was cloned and replaced
692 // on another entity.
693 if ($actualValue instanceof PersistentCollection) {
694 assert($assoc->isToMany());
695 $owner = $actualValue->getOwner();
696 if ($owner === null) { // cloned
697 $actualValue->setOwner($entity, $assoc);
698 } elseif ($owner !== $entity) { // no clone, we have to fix
699 if (! $actualValue->isInitialized()) {
700 $actualValue->initialize(); // we have to do this otherwise the cols share state
701 }
702
703 $newValue = clone $actualValue;
704 $newValue->setOwner($entity, $assoc);
705 $class->reflFields[$propName]->setValue($entity, $newValue);
706 }
707 }
708
709 if ($orgValue instanceof PersistentCollection) {
710 // A PersistentCollection was de-referenced, so delete it.
711 $coid = spl_object_id($orgValue);
712
713 if (isset($this->collectionDeletions[$coid])) {
714 continue;
715 }
716
717 $this->collectionDeletions[$coid] = $orgValue;
718 $changeSet[$propName] = $orgValue; // Signal changeset, to-many assocs will be ignored.
719
720 continue;
721 }
722
723 if ($assoc->isToOne()) {
724 if ($assoc->isOwningSide()) {
725 $changeSet[$propName] = [$orgValue, $actualValue];
726 }
727
728 if ($orgValue !== null && $assoc->orphanRemoval) {
729 assert(is_object($orgValue));
730 $this->scheduleOrphanRemoval($orgValue);
731 }
732 }
733 }
734
735 if ($changeSet) {
736 $this->entityChangeSets[$oid] = $changeSet;
737 $this->originalEntityData[$oid] = $actualData;
738 $this->entityUpdates[$oid] = $entity;
739 }
740 }
741
742 // Look for changes in associations of the entity
743 foreach ($class->associationMappings as $field => $assoc) {
744 $val = $class->reflFields[$field]->getValue($entity);
745 if ($val === null) {
746 continue;
747 }
748
749 $this->computeAssociationChanges($assoc, $val);
750
751 if (
752 ! isset($this->entityChangeSets[$oid]) &&
753 $assoc->isManyToManyOwningSide() &&
754 $val instanceof PersistentCollection &&
755 $val->isDirty()
756 ) {
757 $this->entityChangeSets[$oid] = [];
758 $this->originalEntityData[$oid] = $actualData;
759 $this->entityUpdates[$oid] = $entity;
760 }
761 }
762 }
763
764 /**
765 * Computes all the changes that have been done to entities and collections
766 * since the last commit and stores these changes in the _entityChangeSet map
767 * temporarily for access by the persisters, until the UoW commit is finished.
768 */
769 public function computeChangeSets(): void
770 {
771 // Compute changes for INSERTed entities first. This must always happen.
772 $this->computeScheduleInsertsChangeSets();
773
774 // Compute changes for other MANAGED entities. Change tracking policies take effect here.
775 foreach ($this->identityMap as $className => $entities) {
776 $class = $this->em->getClassMetadata($className);
777
778 // Skip class if instances are read-only
779 if ($class->isReadOnly) {
780 continue;
781 }
782
783 $entitiesToProcess = match (true) {
784 $class->isChangeTrackingDeferredImplicit() => $entities,
785 isset($this->scheduledForSynchronization[$className]) => $this->scheduledForSynchronization[$className],
786 default => [],
787 };
788
789 foreach ($entitiesToProcess as $entity) {
790 // Ignore uninitialized proxy objects
791 if ($this->isUninitializedObject($entity)) {
792 continue;
793 }
794
795 // Only MANAGED entities that are NOT SCHEDULED FOR INSERTION OR DELETION are processed here.
796 $oid = spl_object_id($entity);
797
798 if (! isset($this->entityInsertions[$oid]) && ! isset($this->entityDeletions[$oid]) && isset($this->entityStates[$oid])) {
799 $this->computeChangeSet($class, $entity);
800 }
801 }
802 }
803 }
804
805 /**
806 * Computes the changes of an association.
807 *
808 * @param mixed $value The value of the association.
809 *
810 * @throws ORMInvalidArgumentException
811 * @throws ORMException
812 */
813 private function computeAssociationChanges(AssociationMapping $assoc, mixed $value): void
814 {
815 if ($this->isUninitializedObject($value)) {
816 return;
817 }
818
819 // If this collection is dirty, schedule it for updates
820 if ($value instanceof PersistentCollection && $value->isDirty()) {
821 $coid = spl_object_id($value);
822
823 $this->collectionUpdates[$coid] = $value;
824 $this->visitedCollections[$coid] = $value;
825 }
826
827 // Look through the entities, and in any of their associations,
828 // for transient (new) entities, recursively. ("Persistence by reachability")
829 // Unwrap. Uninitialized collections will simply be empty.
830 $unwrappedValue = $assoc->isToOne() ? [$value] : $value->unwrap();
831 $targetClass = $this->em->getClassMetadata($assoc->targetEntity);
832
833 foreach ($unwrappedValue as $key => $entry) {
834 if (! ($entry instanceof $targetClass->name)) {
835 throw ORMInvalidArgumentException::invalidAssociation($targetClass, $assoc, $entry);
836 }
837
838 $state = $this->getEntityState($entry, self::STATE_NEW);
839
840 if (! ($entry instanceof $assoc->targetEntity)) {
841 throw UnexpectedAssociationValue::create(
842 $assoc->sourceEntity,
843 $assoc->fieldName,
844 get_debug_type($entry),
845 $assoc->targetEntity,
846 );
847 }
848
849 switch ($state) {
850 case self::STATE_NEW:
851 if (! $assoc->isCascadePersist()) {
852 /*
853 * For now just record the details, because this may
854 * not be an issue if we later discover another pathway
855 * through the object-graph where cascade-persistence
856 * is enabled for this object.
857 */
858 $this->nonCascadedNewDetectedEntities[spl_object_id($entry)] = [$assoc, $entry];
859
860 break;
861 }
862
863 $this->persistNew($targetClass, $entry);
864 $this->computeChangeSet($targetClass, $entry);
865
866 break;
867
868 case self::STATE_REMOVED:
869 // Consume the $value as array (it's either an array or an ArrayAccess)
870 // and remove the element from Collection.
871 if (! $assoc->isToMany()) {
872 break;
873 }
874
875 $coid = spl_object_id($value);
876 $this->visitedCollections[$coid] = $value;
877
878 if (! isset($this->pendingCollectionElementRemovals[$coid])) {
879 $this->pendingCollectionElementRemovals[$coid] = [];
880 }
881
882 $this->pendingCollectionElementRemovals[$coid][$key] = true;
883 break;
884
885 case self::STATE_DETACHED:
886 // Can actually not happen right now as we assume STATE_NEW,
887 // so the exception will be raised from the DBAL layer (constraint violation).
888 throw ORMInvalidArgumentException::detachedEntityFoundThroughRelationship($assoc, $entry);
889
890 default:
891 // MANAGED associated entities are already taken into account
892 // during changeset calculation anyway, since they are in the identity map.
893 }
894 }
895 }
896
897 /**
898 * @psalm-param ClassMetadata<T> $class
899 * @psalm-param T $entity
900 *
901 * @template T of object
902 */
903 private function persistNew(ClassMetadata $class, object $entity): void
904 {
905 $oid = spl_object_id($entity);
906 $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::prePersist);
907
908 if ($invoke !== ListenersInvoker::INVOKE_NONE) {
909 $this->listenersInvoker->invoke($class, Events::prePersist, $entity, new PrePersistEventArgs($entity, $this->em), $invoke);
910 }
911
912 $idGen = $class->idGenerator;
913
914 if (! $idGen->isPostInsertGenerator()) {
915 $idValue = $idGen->generateId($this->em, $entity);
916
917 if (! $idGen instanceof AssignedGenerator) {
918 $idValue = [$class->getSingleIdentifierFieldName() => $this->convertSingleFieldIdentifierToPHPValue($class, $idValue)];
919
920 $class->setIdentifierValues($entity, $idValue);
921 }
922
923 // Some identifiers may be foreign keys to new entities.
924 // In this case, we don't have the value yet and should treat it as if we have a post-insert generator
925 if (! $this->hasMissingIdsWhichAreForeignKeys($class, $idValue)) {
926 $this->entityIdentifiers[$oid] = $idValue;
927 }
928 }
929
930 $this->entityStates[$oid] = self::STATE_MANAGED;
931
932 $this->scheduleForInsert($entity);
933 }
934
935 /** @param mixed[] $idValue */
936 private function hasMissingIdsWhichAreForeignKeys(ClassMetadata $class, array $idValue): bool
937 {
938 foreach ($idValue as $idField => $idFieldValue) {
939 if ($idFieldValue === null && isset($class->associationMappings[$idField])) {
940 return true;
941 }
942 }
943
944 return false;
945 }
946
947 /**
948 * INTERNAL:
949 * Computes the changeset of an individual entity, independently of the
950 * computeChangeSets() routine that is used at the beginning of a UnitOfWork#commit().
951 *
952 * The passed entity must be a managed entity. If the entity already has a change set
953 * because this method is invoked during a commit cycle then the change sets are added.
954 * whereby changes detected in this method prevail.
955 *
956 * @param ClassMetadata $class The class descriptor of the entity.
957 * @param object $entity The entity for which to (re)calculate the change set.
958 * @psalm-param ClassMetadata<T> $class
959 * @psalm-param T $entity
960 *
961 * @throws ORMInvalidArgumentException If the passed entity is not MANAGED.
962 *
963 * @template T of object
964 * @ignore
965 */
966 public function recomputeSingleEntityChangeSet(ClassMetadata $class, object $entity): void
967 {
968 $oid = spl_object_id($entity);
969
970 if (! isset($this->entityStates[$oid]) || $this->entityStates[$oid] !== self::STATE_MANAGED) {
971 throw ORMInvalidArgumentException::entityNotManaged($entity);
972 }
973
974 if (! $class->isInheritanceTypeNone()) {
975 $class = $this->em->getClassMetadata($entity::class);
976 }
977
978 $actualData = [];
979
980 foreach ($class->reflFields as $name => $refProp) {
981 if (
982 ( ! $class->isIdentifier($name) || ! $class->isIdGeneratorIdentity())
983 && ($name !== $class->versionField)
984 && ! $class->isCollectionValuedAssociation($name)
985 ) {
986 $actualData[$name] = $refProp->getValue($entity);
987 }
988 }
989
990 if (! isset($this->originalEntityData[$oid])) {
991 throw new RuntimeException('Cannot call recomputeSingleEntityChangeSet before computeChangeSet on an entity.');
992 }
993
994 $originalData = $this->originalEntityData[$oid];
995 $changeSet = [];
996
997 foreach ($actualData as $propName => $actualValue) {
998 $orgValue = $originalData[$propName] ?? null;
999
1000 if (isset($class->fieldMappings[$propName]->enumType)) {
1001 if (is_array($orgValue)) {
1002 foreach ($orgValue as $id => $val) {
1003 if ($val instanceof BackedEnum) {
1004 $orgValue[$id] = $val->value;
1005 }
1006 }
1007 } else {
1008 if ($orgValue instanceof BackedEnum) {
1009 $orgValue = $orgValue->value;
1010 }
1011 }
1012 }
1013
1014 if ($orgValue !== $actualValue) {
1015 $changeSet[$propName] = [$orgValue, $actualValue];
1016 }
1017 }
1018
1019 if ($changeSet) {
1020 if (isset($this->entityChangeSets[$oid])) {
1021 $this->entityChangeSets[$oid] = [...$this->entityChangeSets[$oid], ...$changeSet];
1022 } elseif (! isset($this->entityInsertions[$oid])) {
1023 $this->entityChangeSets[$oid] = $changeSet;
1024 $this->entityUpdates[$oid] = $entity;
1025 }
1026
1027 $this->originalEntityData[$oid] = $actualData;
1028 }
1029 }
1030
1031 /**
1032 * Executes entity insertions
1033 */
1034 private function executeInserts(): void
1035 {
1036 $entities = $this->computeInsertExecutionOrder();
1037 $eventsToDispatch = [];
1038
1039 foreach ($entities as $entity) {
1040 $oid = spl_object_id($entity);
1041 $class = $this->em->getClassMetadata($entity::class);
1042 $persister = $this->getEntityPersister($class->name);
1043
1044 $persister->addInsert($entity);
1045
1046 unset($this->entityInsertions[$oid]);
1047
1048 $persister->executeInserts();
1049
1050 if (! isset($this->entityIdentifiers[$oid])) {
1051 //entity was not added to identity map because some identifiers are foreign keys to new entities.
1052 //add it now
1053 $this->addToEntityIdentifiersAndEntityMap($class, $oid, $entity);
1054 }
1055
1056 $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postPersist);
1057
1058 if ($invoke !== ListenersInvoker::INVOKE_NONE) {
1059 $eventsToDispatch[] = ['class' => $class, 'entity' => $entity, 'invoke' => $invoke];
1060 }
1061 }
1062
1063 // Defer dispatching `postPersist` events to until all entities have been inserted and post-insert
1064 // IDs have been assigned.
1065 foreach ($eventsToDispatch as $event) {
1066 $this->listenersInvoker->invoke(
1067 $event['class'],
1068 Events::postPersist,
1069 $event['entity'],
1070 new PostPersistEventArgs($event['entity'], $this->em),
1071 $event['invoke'],
1072 );
1073 }
1074 }
1075
1076 /**
1077 * @psalm-param ClassMetadata<T> $class
1078 * @psalm-param T $entity
1079 *
1080 * @template T of object
1081 */
1082 private function addToEntityIdentifiersAndEntityMap(
1083 ClassMetadata $class,
1084 int $oid,
1085 object $entity,
1086 ): void {
1087 $identifier = [];
1088
1089 foreach ($class->getIdentifierFieldNames() as $idField) {
1090 $origValue = $class->getFieldValue($entity, $idField);
1091
1092 $value = null;
1093 if (isset($class->associationMappings[$idField])) {
1094 // NOTE: Single Columns as associated identifiers only allowed - this constraint it is enforced.
1095 $value = $this->getSingleIdentifierValue($origValue);
1096 }
1097
1098 $identifier[$idField] = $value ?? $origValue;
1099 $this->originalEntityData[$oid][$idField] = $origValue;
1100 }
1101
1102 $this->entityStates[$oid] = self::STATE_MANAGED;
1103 $this->entityIdentifiers[$oid] = $identifier;
1104
1105 $this->addToIdentityMap($entity);
1106 }
1107
1108 /**
1109 * Executes all entity updates
1110 */
1111 private function executeUpdates(): void
1112 {
1113 foreach ($this->entityUpdates as $oid => $entity) {
1114 $class = $this->em->getClassMetadata($entity::class);
1115 $persister = $this->getEntityPersister($class->name);
1116 $preUpdateInvoke = $this->listenersInvoker->getSubscribedSystems($class, Events::preUpdate);
1117 $postUpdateInvoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postUpdate);
1118
1119 if ($preUpdateInvoke !== ListenersInvoker::INVOKE_NONE) {
1120 $this->listenersInvoker->invoke($class, Events::preUpdate, $entity, new PreUpdateEventArgs($entity, $this->em, $this->getEntityChangeSet($entity)), $preUpdateInvoke);
1121
1122 $this->recomputeSingleEntityChangeSet($class, $entity);
1123 }
1124
1125 if (! empty($this->entityChangeSets[$oid])) {
1126 $persister->update($entity);
1127 }
1128
1129 unset($this->entityUpdates[$oid]);
1130
1131 if ($postUpdateInvoke !== ListenersInvoker::INVOKE_NONE) {
1132 $this->listenersInvoker->invoke($class, Events::postUpdate, $entity, new PostUpdateEventArgs($entity, $this->em), $postUpdateInvoke);
1133 }
1134 }
1135 }
1136
1137 /**
1138 * Executes all entity deletions
1139 */
1140 private function executeDeletions(): void
1141 {
1142 $entities = $this->computeDeleteExecutionOrder();
1143 $eventsToDispatch = [];
1144
1145 foreach ($entities as $entity) {
1146 $this->removeFromIdentityMap($entity);
1147
1148 $oid = spl_object_id($entity);
1149 $class = $this->em->getClassMetadata($entity::class);
1150 $persister = $this->getEntityPersister($class->name);
1151 $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postRemove);
1152
1153 $persister->delete($entity);
1154
1155 unset(
1156 $this->entityDeletions[$oid],
1157 $this->entityIdentifiers[$oid],
1158 $this->originalEntityData[$oid],
1159 $this->entityStates[$oid],
1160 );
1161
1162 // Entity with this $oid after deletion treated as NEW, even if the $oid
1163 // is obtained by a new entity because the old one went out of scope.
1164 //$this->entityStates[$oid] = self::STATE_NEW;
1165 if (! $class->isIdentifierNatural()) {
1166 $class->reflFields[$class->identifier[0]]->setValue($entity, null);
1167 }
1168
1169 if ($invoke !== ListenersInvoker::INVOKE_NONE) {
1170 $eventsToDispatch[] = ['class' => $class, 'entity' => $entity, 'invoke' => $invoke];
1171 }
1172 }
1173
1174 // Defer dispatching `postRemove` events to until all entities have been removed.
1175 foreach ($eventsToDispatch as $event) {
1176 $this->listenersInvoker->invoke(
1177 $event['class'],
1178 Events::postRemove,
1179 $event['entity'],
1180 new PostRemoveEventArgs($event['entity'], $this->em),
1181 $event['invoke'],
1182 );
1183 }
1184 }
1185
1186 /** @return list<object> */
1187 private function computeInsertExecutionOrder(): array
1188 {
1189 $sort = new TopologicalSort();
1190
1191 // First make sure we have all the nodes
1192 foreach ($this->entityInsertions as $entity) {
1193 $sort->addNode($entity);
1194 }
1195
1196 // Now add edges
1197 foreach ($this->entityInsertions as $entity) {
1198 $class = $this->em->getClassMetadata($entity::class);
1199
1200 foreach ($class->associationMappings as $assoc) {
1201 // We only need to consider the owning sides of to-one associations,
1202 // since many-to-many associations are persisted at a later step and
1203 // have no insertion order problems (all entities already in the database
1204 // at that time).
1205 if (! $assoc->isToOneOwningSide()) {
1206 continue;
1207 }
1208
1209 $targetEntity = $class->getFieldValue($entity, $assoc->fieldName);
1210
1211 // If there is no entity that we need to refer to, or it is already in the
1212 // database (i. e. does not have to be inserted), no need to consider it.
1213 if ($targetEntity === null || ! $sort->hasNode($targetEntity)) {
1214 continue;
1215 }
1216
1217 // An entity that references back to itself _and_ uses an application-provided ID
1218 // (the "NONE" generator strategy) can be exempted from commit order computation.
1219 // See https://github.com/doctrine/orm/pull/10735/ for more details on this edge case.
1220 // A non-NULLable self-reference would be a cycle in the graph.
1221 if ($targetEntity === $entity && $class->isIdentifierNatural()) {
1222 continue;
1223 }
1224
1225 // According to https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/annotations-reference.html#annref_joincolumn,
1226 // the default for "nullable" is true. Unfortunately, it seems this default is not applied at the metadata driver, factory or other
1227 // level, but in fact we may have an undefined 'nullable' key here, so we must assume that default here as well.
1228 //
1229 // Same in \Doctrine\ORM\Tools\EntityGenerator::isAssociationIsNullable or \Doctrine\ORM\Persisters\Entity\BasicEntityPersister::getJoinSQLForJoinColumns,
1230 // to give two examples.
1231 $joinColumns = reset($assoc->joinColumns);
1232 $isNullable = ! isset($joinColumns->nullable) || $joinColumns->nullable;
1233
1234 // Add dependency. The dependency direction implies that "$entity depends on $targetEntity". The
1235 // topological sort result will output the depended-upon nodes first, which means we can insert
1236 // entities in that order.
1237 $sort->addEdge($entity, $targetEntity, $isNullable);
1238 }
1239 }
1240
1241 return $sort->sort();
1242 }
1243
1244 /** @return list<object> */
1245 private function computeDeleteExecutionOrder(): array
1246 {
1247 $stronglyConnectedComponents = new StronglyConnectedComponents();
1248 $sort = new TopologicalSort();
1249
1250 foreach ($this->entityDeletions as $entity) {
1251 $stronglyConnectedComponents->addNode($entity);
1252 $sort->addNode($entity);
1253 }
1254
1255 // First, consider only "on delete cascade" associations between entities
1256 // and find strongly connected groups. Once we delete any one of the entities
1257 // in such a group, _all_ of the other entities will be removed as well. So,
1258 // we need to treat those groups like a single entity when performing delete
1259 // order topological sorting.
1260 foreach ($this->entityDeletions as $entity) {
1261 $class = $this->em->getClassMetadata($entity::class);
1262
1263 foreach ($class->associationMappings as $assoc) {
1264 // We only need to consider the owning sides of to-one associations,
1265 // since many-to-many associations can always be (and have already been)
1266 // deleted in a preceding step.
1267 if (! $assoc->isToOneOwningSide()) {
1268 continue;
1269 }
1270
1271 $joinColumns = reset($assoc->joinColumns);
1272 if (! isset($joinColumns->onDelete)) {
1273 continue;
1274 }
1275
1276 $onDeleteOption = strtolower($joinColumns->onDelete);
1277 if ($onDeleteOption !== 'cascade') {
1278 continue;
1279 }
1280
1281 $targetEntity = $class->getFieldValue($entity, $assoc->fieldName);
1282
1283 // If the association does not refer to another entity or that entity
1284 // is not to be deleted, there is no ordering problem and we can
1285 // skip this particular association.
1286 if ($targetEntity === null || ! $stronglyConnectedComponents->hasNode($targetEntity)) {
1287 continue;
1288 }
1289
1290 $stronglyConnectedComponents->addEdge($entity, $targetEntity);
1291 }
1292 }
1293
1294 $stronglyConnectedComponents->findStronglyConnectedComponents();
1295
1296 // Now do the actual topological sorting to find the delete order.
1297 foreach ($this->entityDeletions as $entity) {
1298 $class = $this->em->getClassMetadata($entity::class);
1299
1300 // Get the entities representing the SCC
1301 $entityComponent = $stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($entity);
1302
1303 // When $entity is part of a non-trivial strongly connected component group
1304 // (a group containing not only those entities alone), make sure we process it _after_ the
1305 // entity representing the group.
1306 // The dependency direction implies that "$entity depends on $entityComponent
1307 // being deleted first". The topological sort will output the depended-upon nodes first.
1308 if ($entityComponent !== $entity) {
1309 $sort->addEdge($entity, $entityComponent, false);
1310 }
1311
1312 foreach ($class->associationMappings as $assoc) {
1313 // We only need to consider the owning sides of to-one associations,
1314 // since many-to-many associations can always be (and have already been)
1315 // deleted in a preceding step.
1316 if (! $assoc->isToOneOwningSide()) {
1317 continue;
1318 }
1319
1320 // For associations that implement a database-level set null operation,
1321 // we do not have to follow a particular order: If the referred-to entity is
1322 // deleted first, the DBMS will temporarily set the foreign key to NULL (SET NULL).
1323 // So, we can skip it in the computation.
1324 $joinColumns = reset($assoc->joinColumns);
1325 if (isset($joinColumns->onDelete)) {
1326 $onDeleteOption = strtolower($joinColumns->onDelete);
1327 if ($onDeleteOption === 'set null') {
1328 continue;
1329 }
1330 }
1331
1332 $targetEntity = $class->getFieldValue($entity, $assoc->fieldName);
1333
1334 // If the association does not refer to another entity or that entity
1335 // is not to be deleted, there is no ordering problem and we can
1336 // skip this particular association.
1337 if ($targetEntity === null || ! $sort->hasNode($targetEntity)) {
1338 continue;
1339 }
1340
1341 // Get the entities representing the SCC
1342 $targetEntityComponent = $stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($targetEntity);
1343
1344 // When we have a dependency between two different groups of strongly connected nodes,
1345 // add it to the computation.
1346 // The dependency direction implies that "$targetEntityComponent depends on $entityComponent
1347 // being deleted first". The topological sort will output the depended-upon nodes first,
1348 // so we can work through the result in the returned order.
1349 if ($targetEntityComponent !== $entityComponent) {
1350 $sort->addEdge($targetEntityComponent, $entityComponent, false);
1351 }
1352 }
1353 }
1354
1355 return $sort->sort();
1356 }
1357
1358 /**
1359 * Schedules an entity for insertion into the database.
1360 * If the entity already has an identifier, it will be added to the identity map.
1361 *
1362 * @throws ORMInvalidArgumentException
1363 * @throws InvalidArgumentException
1364 */
1365 public function scheduleForInsert(object $entity): void
1366 {
1367 $oid = spl_object_id($entity);
1368
1369 if (isset($this->entityUpdates[$oid])) {
1370 throw new InvalidArgumentException('Dirty entity can not be scheduled for insertion.');
1371 }
1372
1373 if (isset($this->entityDeletions[$oid])) {
1374 throw ORMInvalidArgumentException::scheduleInsertForRemovedEntity($entity);
1375 }
1376
1377 if (isset($this->originalEntityData[$oid]) && ! isset($this->entityInsertions[$oid])) {
1378 throw ORMInvalidArgumentException::scheduleInsertForManagedEntity($entity);
1379 }
1380
1381 if (isset($this->entityInsertions[$oid])) {
1382 throw ORMInvalidArgumentException::scheduleInsertTwice($entity);
1383 }
1384
1385 $this->entityInsertions[$oid] = $entity;
1386
1387 if (isset($this->entityIdentifiers[$oid])) {
1388 $this->addToIdentityMap($entity);
1389 }
1390 }
1391
1392 /**
1393 * Checks whether an entity is scheduled for insertion.
1394 */
1395 public function isScheduledForInsert(object $entity): bool
1396 {
1397 return isset($this->entityInsertions[spl_object_id($entity)]);
1398 }
1399
1400 /**
1401 * Schedules an entity for being updated.
1402 *
1403 * @throws ORMInvalidArgumentException
1404 */
1405 public function scheduleForUpdate(object $entity): void
1406 {
1407 $oid = spl_object_id($entity);
1408
1409 if (! isset($this->entityIdentifiers[$oid])) {
1410 throw ORMInvalidArgumentException::entityHasNoIdentity($entity, 'scheduling for update');
1411 }
1412
1413 if (isset($this->entityDeletions[$oid])) {
1414 throw ORMInvalidArgumentException::entityIsRemoved($entity, 'schedule for update');
1415 }
1416
1417 if (! isset($this->entityUpdates[$oid]) && ! isset($this->entityInsertions[$oid])) {
1418 $this->entityUpdates[$oid] = $entity;
1419 }
1420 }
1421
1422 /**
1423 * INTERNAL:
1424 * Schedules an extra update that will be executed immediately after the
1425 * regular entity updates within the currently running commit cycle.
1426 *
1427 * Extra updates for entities are stored as (entity, changeset) tuples.
1428 *
1429 * @psalm-param array<string, array{mixed, mixed}> $changeset The changeset of the entity (what to update).
1430 *
1431 * @ignore
1432 */
1433 public function scheduleExtraUpdate(object $entity, array $changeset): void
1434 {
1435 $oid = spl_object_id($entity);
1436 $extraUpdate = [$entity, $changeset];
1437
1438 if (isset($this->extraUpdates[$oid])) {
1439 [, $changeset2] = $this->extraUpdates[$oid];
1440
1441 $extraUpdate = [$entity, $changeset + $changeset2];
1442 }
1443
1444 $this->extraUpdates[$oid] = $extraUpdate;
1445 }
1446
1447 /**
1448 * Checks whether an entity is registered as dirty in the unit of work.
1449 * Note: Is not very useful currently as dirty entities are only registered
1450 * at commit time.
1451 */
1452 public function isScheduledForUpdate(object $entity): bool
1453 {
1454 return isset($this->entityUpdates[spl_object_id($entity)]);
1455 }
1456
1457 /**
1458 * Checks whether an entity is registered to be checked in the unit of work.
1459 */
1460 public function isScheduledForDirtyCheck(object $entity): bool
1461 {
1462 $rootEntityName = $this->em->getClassMetadata($entity::class)->rootEntityName;
1463
1464 return isset($this->scheduledForSynchronization[$rootEntityName][spl_object_id($entity)]);
1465 }
1466
1467 /**
1468 * INTERNAL:
1469 * Schedules an entity for deletion.
1470 */
1471 public function scheduleForDelete(object $entity): void
1472 {
1473 $oid = spl_object_id($entity);
1474
1475 if (isset($this->entityInsertions[$oid])) {
1476 if ($this->isInIdentityMap($entity)) {
1477 $this->removeFromIdentityMap($entity);
1478 }
1479
1480 unset($this->entityInsertions[$oid], $this->entityStates[$oid]);
1481
1482 return; // entity has not been persisted yet, so nothing more to do.
1483 }
1484
1485 if (! $this->isInIdentityMap($entity)) {
1486 return;
1487 }
1488
1489 unset($this->entityUpdates[$oid]);
1490
1491 if (! isset($this->entityDeletions[$oid])) {
1492 $this->entityDeletions[$oid] = $entity;
1493 $this->entityStates[$oid] = self::STATE_REMOVED;
1494 }
1495 }
1496
1497 /**
1498 * Checks whether an entity is registered as removed/deleted with the unit
1499 * of work.
1500 */
1501 public function isScheduledForDelete(object $entity): bool
1502 {
1503 return isset($this->entityDeletions[spl_object_id($entity)]);
1504 }
1505
1506 /**
1507 * Checks whether an entity is scheduled for insertion, update or deletion.
1508 */
1509 public function isEntityScheduled(object $entity): bool
1510 {
1511 $oid = spl_object_id($entity);
1512
1513 return isset($this->entityInsertions[$oid])
1514 || isset($this->entityUpdates[$oid])
1515 || isset($this->entityDeletions[$oid]);
1516 }
1517
1518 /**
1519 * INTERNAL:
1520 * Registers an entity in the identity map.
1521 * Note that entities in a hierarchy are registered with the class name of
1522 * the root entity.
1523 *
1524 * @return bool TRUE if the registration was successful, FALSE if the identity of
1525 * the entity in question is already managed.
1526 *
1527 * @throws ORMInvalidArgumentException
1528 * @throws EntityIdentityCollisionException
1529 *
1530 * @ignore
1531 */
1532 public function addToIdentityMap(object $entity): bool
1533 {
1534 $classMetadata = $this->em->getClassMetadata($entity::class);
1535 $idHash = $this->getIdHashByEntity($entity);
1536 $className = $classMetadata->rootEntityName;
1537
1538 if (isset($this->identityMap[$className][$idHash])) {
1539 if ($this->identityMap[$className][$idHash] !== $entity) {
1540 throw EntityIdentityCollisionException::create($this->identityMap[$className][$idHash], $entity, $idHash);
1541 }
1542
1543 return false;
1544 }
1545
1546 $this->identityMap[$className][$idHash] = $entity;
1547
1548 return true;
1549 }
1550
1551 /**
1552 * Gets the id hash of an entity by its identifier.
1553 *
1554 * @param array<string|int, mixed> $identifier The identifier of an entity
1555 *
1556 * @return string The entity id hash.
1557 */
1558 final public static function getIdHashByIdentifier(array $identifier): string
1559 {
1560 foreach ($identifier as $k => $value) {
1561 if ($value instanceof BackedEnum) {
1562 $identifier[$k] = $value->value;
1563 }
1564 }
1565
1566 return implode(
1567 ' ',
1568 $identifier,
1569 );
1570 }
1571
1572 /**
1573 * Gets the id hash of an entity.
1574 *
1575 * @param object $entity The entity managed by Unit Of Work
1576 *
1577 * @return string The entity id hash.
1578 */
1579 public function getIdHashByEntity(object $entity): string
1580 {
1581 $identifier = $this->entityIdentifiers[spl_object_id($entity)];
1582
1583 if (empty($identifier) || in_array(null, $identifier, true)) {
1584 $classMetadata = $this->em->getClassMetadata($entity::class);
1585
1586 throw ORMInvalidArgumentException::entityWithoutIdentity($classMetadata->name, $entity);
1587 }
1588
1589 return self::getIdHashByIdentifier($identifier);
1590 }
1591
1592 /**
1593 * Gets the state of an entity with regard to the current unit of work.
1594 *
1595 * @param int|null $assume The state to assume if the state is not yet known (not MANAGED or REMOVED).
1596 * This parameter can be set to improve performance of entity state detection
1597 * by potentially avoiding a database lookup if the distinction between NEW and DETACHED
1598 * is either known or does not matter for the caller of the method.
1599 * @psalm-param self::STATE_*|null $assume
1600 *
1601 * @psalm-return self::STATE_*
1602 */
1603 public function getEntityState(object $entity, int|null $assume = null): int
1604 {
1605 $oid = spl_object_id($entity);
1606
1607 if (isset($this->entityStates[$oid])) {
1608 return $this->entityStates[$oid];
1609 }
1610
1611 if ($assume !== null) {
1612 return $assume;
1613 }
1614
1615 // State can only be NEW or DETACHED, because MANAGED/REMOVED states are known.
1616 // Note that you can not remember the NEW or DETACHED state in _entityStates since
1617 // the UoW does not hold references to such objects and the object hash can be reused.
1618 // More generally because the state may "change" between NEW/DETACHED without the UoW being aware of it.
1619 $class = $this->em->getClassMetadata($entity::class);
1620 $id = $class->getIdentifierValues($entity);
1621
1622 if (! $id) {
1623 return self::STATE_NEW;
1624 }
1625
1626 if ($class->containsForeignIdentifier || $class->containsEnumIdentifier) {
1627 $id = $this->identifierFlattener->flattenIdentifier($class, $id);
1628 }
1629
1630 switch (true) {
1631 case $class->isIdentifierNatural():
1632 // Check for a version field, if available, to avoid a db lookup.
1633 if ($class->isVersioned) {
1634 assert($class->versionField !== null);
1635
1636 return $class->getFieldValue($entity, $class->versionField)
1637 ? self::STATE_DETACHED
1638 : self::STATE_NEW;
1639 }
1640
1641 // Last try before db lookup: check the identity map.
1642 if ($this->tryGetById($id, $class->rootEntityName)) {
1643 return self::STATE_DETACHED;
1644 }
1645
1646 // db lookup
1647 if ($this->getEntityPersister($class->name)->exists($entity)) {
1648 return self::STATE_DETACHED;
1649 }
1650
1651 return self::STATE_NEW;
1652
1653 case ! $class->idGenerator->isPostInsertGenerator():
1654 // if we have a pre insert generator we can't be sure that having an id
1655 // really means that the entity exists. We have to verify this through
1656 // the last resort: a db lookup
1657
1658 // Last try before db lookup: check the identity map.
1659 if ($this->tryGetById($id, $class->rootEntityName)) {
1660 return self::STATE_DETACHED;
1661 }
1662
1663 // db lookup
1664 if ($this->getEntityPersister($class->name)->exists($entity)) {
1665 return self::STATE_DETACHED;
1666 }
1667
1668 return self::STATE_NEW;
1669
1670 default:
1671 return self::STATE_DETACHED;
1672 }
1673 }
1674
1675 /**
1676 * INTERNAL:
1677 * Removes an entity from the identity map. This effectively detaches the
1678 * entity from the persistence management of Doctrine.
1679 *
1680 * @throws ORMInvalidArgumentException
1681 *
1682 * @ignore
1683 */
1684 public function removeFromIdentityMap(object $entity): bool
1685 {
1686 $oid = spl_object_id($entity);
1687 $classMetadata = $this->em->getClassMetadata($entity::class);
1688 $idHash = self::getIdHashByIdentifier($this->entityIdentifiers[$oid]);
1689
1690 if ($idHash === '') {
1691 throw ORMInvalidArgumentException::entityHasNoIdentity($entity, 'remove from identity map');
1692 }
1693
1694 $className = $classMetadata->rootEntityName;
1695
1696 if (isset($this->identityMap[$className][$idHash])) {
1697 unset($this->identityMap[$className][$idHash], $this->readOnlyObjects[$oid]);
1698
1699 //$this->entityStates[$oid] = self::STATE_DETACHED;
1700
1701 return true;
1702 }
1703
1704 return false;
1705 }
1706
1707 /**
1708 * INTERNAL:
1709 * Gets an entity in the identity map by its identifier hash.
1710 *
1711 * @ignore
1712 */
1713 public function getByIdHash(string $idHash, string $rootClassName): object|null
1714 {
1715 return $this->identityMap[$rootClassName][$idHash];
1716 }
1717
1718 /**
1719 * INTERNAL:
1720 * Tries to get an entity by its identifier hash. If no entity is found for
1721 * the given hash, FALSE is returned.
1722 *
1723 * @param mixed $idHash (must be possible to cast it to string)
1724 *
1725 * @return false|object The found entity or FALSE.
1726 *
1727 * @ignore
1728 */
1729 public function tryGetByIdHash(mixed $idHash, string $rootClassName): object|false
1730 {
1731 $stringIdHash = (string) $idHash;
1732
1733 return $this->identityMap[$rootClassName][$stringIdHash] ?? false;
1734 }
1735
1736 /**
1737 * Checks whether an entity is registered in the identity map of this UnitOfWork.
1738 */
1739 public function isInIdentityMap(object $entity): bool
1740 {
1741 $oid = spl_object_id($entity);
1742
1743 if (empty($this->entityIdentifiers[$oid])) {
1744 return false;
1745 }
1746
1747 $classMetadata = $this->em->getClassMetadata($entity::class);
1748 $idHash = self::getIdHashByIdentifier($this->entityIdentifiers[$oid]);
1749
1750 return isset($this->identityMap[$classMetadata->rootEntityName][$idHash]);
1751 }
1752
1753 /**
1754 * Persists an entity as part of the current unit of work.
1755 */
1756 public function persist(object $entity): void
1757 {
1758 $visited = [];
1759
1760 $this->doPersist($entity, $visited);
1761 }
1762
1763 /**
1764 * Persists an entity as part of the current unit of work.
1765 *
1766 * This method is internally called during persist() cascades as it tracks
1767 * the already visited entities to prevent infinite recursions.
1768 *
1769 * @psalm-param array<int, object> $visited The already visited entities.
1770 *
1771 * @throws ORMInvalidArgumentException
1772 * @throws UnexpectedValueException
1773 */
1774 private function doPersist(object $entity, array &$visited): void
1775 {
1776 $oid = spl_object_id($entity);
1777
1778 if (isset($visited[$oid])) {
1779 return; // Prevent infinite recursion
1780 }
1781
1782 $visited[$oid] = $entity; // Mark visited
1783
1784 $class = $this->em->getClassMetadata($entity::class);
1785
1786 // We assume NEW, so DETACHED entities result in an exception on flush (constraint violation).
1787 // If we would detect DETACHED here we would throw an exception anyway with the same
1788 // consequences (not recoverable/programming error), so just assuming NEW here
1789 // lets us avoid some database lookups for entities with natural identifiers.
1790 $entityState = $this->getEntityState($entity, self::STATE_NEW);
1791
1792 switch ($entityState) {
1793 case self::STATE_MANAGED:
1794 // Nothing to do, except if policy is "deferred explicit"
1795 if ($class->isChangeTrackingDeferredExplicit()) {
1796 $this->scheduleForDirtyCheck($entity);
1797 }
1798
1799 break;
1800
1801 case self::STATE_NEW:
1802 $this->persistNew($class, $entity);
1803 break;
1804
1805 case self::STATE_REMOVED:
1806 // Entity becomes managed again
1807 unset($this->entityDeletions[$oid]);
1808 $this->addToIdentityMap($entity);
1809
1810 $this->entityStates[$oid] = self::STATE_MANAGED;
1811
1812 if ($class->isChangeTrackingDeferredExplicit()) {
1813 $this->scheduleForDirtyCheck($entity);
1814 }
1815
1816 break;
1817
1818 case self::STATE_DETACHED:
1819 // Can actually not happen right now since we assume STATE_NEW.
1820 throw ORMInvalidArgumentException::detachedEntityCannot($entity, 'persisted');
1821
1822 default:
1823 throw new UnexpectedValueException(sprintf(
1824 'Unexpected entity state: %s. %s',
1825 $entityState,
1826 self::objToStr($entity),
1827 ));
1828 }
1829
1830 $this->cascadePersist($entity, $visited);
1831 }
1832
1833 /**
1834 * Deletes an entity as part of the current unit of work.
1835 */
1836 public function remove(object $entity): void
1837 {
1838 $visited = [];
1839
1840 $this->doRemove($entity, $visited);
1841 }
1842
1843 /**
1844 * Deletes an entity as part of the current unit of work.
1845 *
1846 * This method is internally called during delete() cascades as it tracks
1847 * the already visited entities to prevent infinite recursions.
1848 *
1849 * @psalm-param array<int, object> $visited The map of the already visited entities.
1850 *
1851 * @throws ORMInvalidArgumentException If the instance is a detached entity.
1852 * @throws UnexpectedValueException
1853 */
1854 private function doRemove(object $entity, array &$visited): void
1855 {
1856 $oid = spl_object_id($entity);
1857
1858 if (isset($visited[$oid])) {
1859 return; // Prevent infinite recursion
1860 }
1861
1862 $visited[$oid] = $entity; // mark visited
1863
1864 // Cascade first, because scheduleForDelete() removes the entity from the identity map, which
1865 // can cause problems when a lazy proxy has to be initialized for the cascade operation.
1866 $this->cascadeRemove($entity, $visited);
1867
1868 $class = $this->em->getClassMetadata($entity::class);
1869 $entityState = $this->getEntityState($entity);
1870
1871 switch ($entityState) {
1872 case self::STATE_NEW:
1873 case self::STATE_REMOVED:
1874 // nothing to do
1875 break;
1876
1877 case self::STATE_MANAGED:
1878 $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::preRemove);
1879
1880 if ($invoke !== ListenersInvoker::INVOKE_NONE) {
1881 $this->listenersInvoker->invoke($class, Events::preRemove, $entity, new PreRemoveEventArgs($entity, $this->em), $invoke);
1882 }
1883
1884 $this->scheduleForDelete($entity);
1885 break;
1886
1887 case self::STATE_DETACHED:
1888 throw ORMInvalidArgumentException::detachedEntityCannot($entity, 'removed');
1889
1890 default:
1891 throw new UnexpectedValueException(sprintf(
1892 'Unexpected entity state: %s. %s',
1893 $entityState,
1894 self::objToStr($entity),
1895 ));
1896 }
1897 }
1898
1899 /**
1900 * Detaches an entity from the persistence management. It's persistence will
1901 * no longer be managed by Doctrine.
1902 */
1903 public function detach(object $entity): void
1904 {
1905 $visited = [];
1906
1907 $this->doDetach($entity, $visited);
1908 }
1909
1910 /**
1911 * Executes a detach operation on the given entity.
1912 *
1913 * @param mixed[] $visited
1914 * @param bool $noCascade if true, don't cascade detach operation.
1915 */
1916 private function doDetach(
1917 object $entity,
1918 array &$visited,
1919 bool $noCascade = false,
1920 ): void {
1921 $oid = spl_object_id($entity);
1922
1923 if (isset($visited[$oid])) {
1924 return; // Prevent infinite recursion
1925 }
1926
1927 $visited[$oid] = $entity; // mark visited
1928
1929 switch ($this->getEntityState($entity, self::STATE_DETACHED)) {
1930 case self::STATE_MANAGED:
1931 if ($this->isInIdentityMap($entity)) {
1932 $this->removeFromIdentityMap($entity);
1933 }
1934
1935 unset(
1936 $this->entityInsertions[$oid],
1937 $this->entityUpdates[$oid],
1938 $this->entityDeletions[$oid],
1939 $this->entityIdentifiers[$oid],
1940 $this->entityStates[$oid],
1941 $this->originalEntityData[$oid],
1942 );
1943 break;
1944 case self::STATE_NEW:
1945 case self::STATE_DETACHED:
1946 return;
1947 }
1948
1949 if (! $noCascade) {
1950 $this->cascadeDetach($entity, $visited);
1951 }
1952 }
1953
1954 /**
1955 * Refreshes the state of the given entity from the database, overwriting
1956 * any local, unpersisted changes.
1957 *
1958 * @psalm-param LockMode::*|null $lockMode
1959 *
1960 * @throws InvalidArgumentException If the entity is not MANAGED.
1961 * @throws TransactionRequiredException
1962 */
1963 public function refresh(object $entity, LockMode|int|null $lockMode = null): void
1964 {
1965 $visited = [];
1966
1967 $this->doRefresh($entity, $visited, $lockMode);
1968 }
1969
1970 /**
1971 * Executes a refresh operation on an entity.
1972 *
1973 * @psalm-param array<int, object> $visited The already visited entities during cascades.
1974 * @psalm-param LockMode::*|null $lockMode
1975 *
1976 * @throws ORMInvalidArgumentException If the entity is not MANAGED.
1977 * @throws TransactionRequiredException
1978 */
1979 private function doRefresh(object $entity, array &$visited, LockMode|int|null $lockMode = null): void
1980 {
1981 switch (true) {
1982 case $lockMode === LockMode::PESSIMISTIC_READ:
1983 case $lockMode === LockMode::PESSIMISTIC_WRITE:
1984 if (! $this->em->getConnection()->isTransactionActive()) {
1985 throw TransactionRequiredException::transactionRequired();
1986 }
1987 }
1988
1989 $oid = spl_object_id($entity);
1990
1991 if (isset($visited[$oid])) {
1992 return; // Prevent infinite recursion
1993 }
1994
1995 $visited[$oid] = $entity; // mark visited
1996
1997 $class = $this->em->getClassMetadata($entity::class);
1998
1999 if ($this->getEntityState($entity) !== self::STATE_MANAGED) {
2000 throw ORMInvalidArgumentException::entityNotManaged($entity);
2001 }
2002
2003 $this->getEntityPersister($class->name)->refresh(
2004 array_combine($class->getIdentifierFieldNames(), $this->entityIdentifiers[$oid]),
2005 $entity,
2006 $lockMode,
2007 );
2008
2009 $this->cascadeRefresh($entity, $visited, $lockMode);
2010 }
2011
2012 /**
2013 * Cascades a refresh operation to associated entities.
2014 *
2015 * @psalm-param array<int, object> $visited
2016 * @psalm-param LockMode::*|null $lockMode
2017 */
2018 private function cascadeRefresh(object $entity, array &$visited, LockMode|int|null $lockMode = null): void
2019 {
2020 $class = $this->em->getClassMetadata($entity::class);
2021
2022 $associationMappings = array_filter(
2023 $class->associationMappings,
2024 static fn (AssociationMapping $assoc): bool => $assoc->isCascadeRefresh()
2025 );
2026
2027 foreach ($associationMappings as $assoc) {
2028 $relatedEntities = $class->reflFields[$assoc->fieldName]->getValue($entity);
2029
2030 switch (true) {
2031 case $relatedEntities instanceof PersistentCollection:
2032 // Unwrap so that foreach() does not initialize
2033 $relatedEntities = $relatedEntities->unwrap();
2034 // break; is commented intentionally!
2035
2036 case $relatedEntities instanceof Collection:
2037 case is_array($relatedEntities):
2038 foreach ($relatedEntities as $relatedEntity) {
2039 $this->doRefresh($relatedEntity, $visited, $lockMode);
2040 }
2041
2042 break;
2043
2044 case $relatedEntities !== null:
2045 $this->doRefresh($relatedEntities, $visited, $lockMode);
2046 break;
2047
2048 default:
2049 // Do nothing
2050 }
2051 }
2052 }
2053
2054 /**
2055 * Cascades a detach operation to associated entities.
2056 *
2057 * @param array<int, object> $visited
2058 */
2059 private function cascadeDetach(object $entity, array &$visited): void
2060 {
2061 $class = $this->em->getClassMetadata($entity::class);
2062
2063 $associationMappings = array_filter(
2064 $class->associationMappings,
2065 static fn (AssociationMapping $assoc): bool => $assoc->isCascadeDetach()
2066 );
2067
2068 foreach ($associationMappings as $assoc) {
2069 $relatedEntities = $class->reflFields[$assoc->fieldName]->getValue($entity);
2070
2071 switch (true) {
2072 case $relatedEntities instanceof PersistentCollection:
2073 // Unwrap so that foreach() does not initialize
2074 $relatedEntities = $relatedEntities->unwrap();
2075 // break; is commented intentionally!
2076
2077 case $relatedEntities instanceof Collection:
2078 case is_array($relatedEntities):
2079 foreach ($relatedEntities as $relatedEntity) {
2080 $this->doDetach($relatedEntity, $visited);
2081 }
2082
2083 break;
2084
2085 case $relatedEntities !== null:
2086 $this->doDetach($relatedEntities, $visited);
2087 break;
2088
2089 default:
2090 // Do nothing
2091 }
2092 }
2093 }
2094
2095 /**
2096 * Cascades the save operation to associated entities.
2097 *
2098 * @psalm-param array<int, object> $visited
2099 */
2100 private function cascadePersist(object $entity, array &$visited): void
2101 {
2102 if ($this->isUninitializedObject($entity)) {
2103 // nothing to do - proxy is not initialized, therefore we don't do anything with it
2104 return;
2105 }
2106
2107 $class = $this->em->getClassMetadata($entity::class);
2108
2109 $associationMappings = array_filter(
2110 $class->associationMappings,
2111 static fn (AssociationMapping $assoc): bool => $assoc->isCascadePersist()
2112 );
2113
2114 foreach ($associationMappings as $assoc) {
2115 $relatedEntities = $class->reflFields[$assoc->fieldName]->getValue($entity);
2116
2117 switch (true) {
2118 case $relatedEntities instanceof PersistentCollection:
2119 // Unwrap so that foreach() does not initialize
2120 $relatedEntities = $relatedEntities->unwrap();
2121 // break; is commented intentionally!
2122
2123 case $relatedEntities instanceof Collection:
2124 case is_array($relatedEntities):
2125 if ($assoc->isToMany() <= 0) {
2126 throw ORMInvalidArgumentException::invalidAssociation(
2127 $this->em->getClassMetadata($assoc->targetEntity),
2128 $assoc,
2129 $relatedEntities,
2130 );
2131 }
2132
2133 foreach ($relatedEntities as $relatedEntity) {
2134 $this->doPersist($relatedEntity, $visited);
2135 }
2136
2137 break;
2138
2139 case $relatedEntities !== null:
2140 if (! $relatedEntities instanceof $assoc->targetEntity) {
2141 throw ORMInvalidArgumentException::invalidAssociation(
2142 $this->em->getClassMetadata($assoc->targetEntity),
2143 $assoc,
2144 $relatedEntities,
2145 );
2146 }
2147
2148 $this->doPersist($relatedEntities, $visited);
2149 break;
2150
2151 default:
2152 // Do nothing
2153 }
2154 }
2155 }
2156
2157 /**
2158 * Cascades the delete operation to associated entities.
2159 *
2160 * @psalm-param array<int, object> $visited
2161 */
2162 private function cascadeRemove(object $entity, array &$visited): void
2163 {
2164 $class = $this->em->getClassMetadata($entity::class);
2165
2166 $associationMappings = array_filter(
2167 $class->associationMappings,
2168 static fn (AssociationMapping $assoc): bool => $assoc->isCascadeRemove()
2169 );
2170
2171 if ($associationMappings) {
2172 $this->initializeObject($entity);
2173 }
2174
2175 $entitiesToCascade = [];
2176
2177 foreach ($associationMappings as $assoc) {
2178 $relatedEntities = $class->reflFields[$assoc->fieldName]->getValue($entity);
2179
2180 switch (true) {
2181 case $relatedEntities instanceof Collection:
2182 case is_array($relatedEntities):
2183 // If its a PersistentCollection initialization is intended! No unwrap!
2184 foreach ($relatedEntities as $relatedEntity) {
2185 $entitiesToCascade[] = $relatedEntity;
2186 }
2187
2188 break;
2189
2190 case $relatedEntities !== null:
2191 $entitiesToCascade[] = $relatedEntities;
2192 break;
2193
2194 default:
2195 // Do nothing
2196 }
2197 }
2198
2199 foreach ($entitiesToCascade as $relatedEntity) {
2200 $this->doRemove($relatedEntity, $visited);
2201 }
2202 }
2203
2204 /**
2205 * Acquire a lock on the given entity.
2206 *
2207 * @psalm-param LockMode::* $lockMode
2208 *
2209 * @throws ORMInvalidArgumentException
2210 * @throws TransactionRequiredException
2211 * @throws OptimisticLockException
2212 */
2213 public function lock(object $entity, LockMode|int $lockMode, DateTimeInterface|int|null $lockVersion = null): void
2214 {
2215 if ($this->getEntityState($entity, self::STATE_DETACHED) !== self::STATE_MANAGED) {
2216 throw ORMInvalidArgumentException::entityNotManaged($entity);
2217 }
2218
2219 $class = $this->em->getClassMetadata($entity::class);
2220
2221 switch (true) {
2222 case $lockMode === LockMode::OPTIMISTIC:
2223 if (! $class->isVersioned) {
2224 throw OptimisticLockException::notVersioned($class->name);
2225 }
2226
2227 if ($lockVersion === null) {
2228 return;
2229 }
2230
2231 $this->initializeObject($entity);
2232
2233 assert($class->versionField !== null);
2234 $entityVersion = $class->reflFields[$class->versionField]->getValue($entity);
2235
2236 // phpcs:ignore SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedNotEqualOperator
2237 if ($entityVersion != $lockVersion) {
2238 throw OptimisticLockException::lockFailedVersionMismatch($entity, $lockVersion, $entityVersion);
2239 }
2240
2241 break;
2242
2243 case $lockMode === LockMode::NONE:
2244 case $lockMode === LockMode::PESSIMISTIC_READ:
2245 case $lockMode === LockMode::PESSIMISTIC_WRITE:
2246 if (! $this->em->getConnection()->isTransactionActive()) {
2247 throw TransactionRequiredException::transactionRequired();
2248 }
2249
2250 $oid = spl_object_id($entity);
2251
2252 $this->getEntityPersister($class->name)->lock(
2253 array_combine($class->getIdentifierFieldNames(), $this->entityIdentifiers[$oid]),
2254 $lockMode,
2255 );
2256 break;
2257
2258 default:
2259 // Do nothing
2260 }
2261 }
2262
2263 /**
2264 * Clears the UnitOfWork.
2265 */
2266 public function clear(): void
2267 {
2268 $this->identityMap =
2269 $this->entityIdentifiers =
2270 $this->originalEntityData =
2271 $this->entityChangeSets =
2272 $this->entityStates =
2273 $this->scheduledForSynchronization =
2274 $this->entityInsertions =
2275 $this->entityUpdates =
2276 $this->entityDeletions =
2277 $this->nonCascadedNewDetectedEntities =
2278 $this->collectionDeletions =
2279 $this->collectionUpdates =
2280 $this->extraUpdates =
2281 $this->readOnlyObjects =
2282 $this->pendingCollectionElementRemovals =
2283 $this->visitedCollections =
2284 $this->eagerLoadingEntities =
2285 $this->eagerLoadingCollections =
2286 $this->orphanRemovals = [];
2287
2288 if ($this->evm->hasListeners(Events::onClear)) {
2289 $this->evm->dispatchEvent(Events::onClear, new OnClearEventArgs($this->em));
2290 }
2291 }
2292
2293 /**
2294 * INTERNAL:
2295 * Schedules an orphaned entity for removal. The remove() operation will be
2296 * invoked on that entity at the beginning of the next commit of this
2297 * UnitOfWork.
2298 *
2299 * @ignore
2300 */
2301 public function scheduleOrphanRemoval(object $entity): void
2302 {
2303 $this->orphanRemovals[spl_object_id($entity)] = $entity;
2304 }
2305
2306 /**
2307 * INTERNAL:
2308 * Cancels a previously scheduled orphan removal.
2309 *
2310 * @ignore
2311 */
2312 public function cancelOrphanRemoval(object $entity): void
2313 {
2314 unset($this->orphanRemovals[spl_object_id($entity)]);
2315 }
2316
2317 /**
2318 * INTERNAL:
2319 * Schedules a complete collection for removal when this UnitOfWork commits.
2320 */
2321 public function scheduleCollectionDeletion(PersistentCollection $coll): void
2322 {
2323 $coid = spl_object_id($coll);
2324
2325 // TODO: if $coll is already scheduled for recreation ... what to do?
2326 // Just remove $coll from the scheduled recreations?
2327 unset($this->collectionUpdates[$coid]);
2328
2329 $this->collectionDeletions[$coid] = $coll;
2330 }
2331
2332 public function isCollectionScheduledForDeletion(PersistentCollection $coll): bool
2333 {
2334 return isset($this->collectionDeletions[spl_object_id($coll)]);
2335 }
2336
2337 /**
2338 * INTERNAL:
2339 * Creates an entity. Used for reconstitution of persistent entities.
2340 *
2341 * Internal note: Highly performance-sensitive method.
2342 *
2343 * @param string $className The name of the entity class.
2344 * @param mixed[] $data The data for the entity.
2345 * @param mixed[] $hints Any hints to account for during reconstitution/lookup of the entity.
2346 * @psalm-param class-string $className
2347 * @psalm-param array<string, mixed> $hints
2348 *
2349 * @return object The managed entity instance.
2350 *
2351 * @ignore
2352 * @todo Rename: getOrCreateEntity
2353 */
2354 public function createEntity(string $className, array $data, array &$hints = []): object
2355 {
2356 $class = $this->em->getClassMetadata($className);
2357
2358 $id = $this->identifierFlattener->flattenIdentifier($class, $data);
2359 $idHash = self::getIdHashByIdentifier($id);
2360
2361 if (isset($this->identityMap[$class->rootEntityName][$idHash])) {
2362 $entity = $this->identityMap[$class->rootEntityName][$idHash];
2363 $oid = spl_object_id($entity);
2364
2365 if (
2366 isset($hints[Query::HINT_REFRESH], $hints[Query::HINT_REFRESH_ENTITY])
2367 ) {
2368 $unmanagedProxy = $hints[Query::HINT_REFRESH_ENTITY];
2369 if (
2370 $unmanagedProxy !== $entity
2371 && $this->isIdentifierEquals($unmanagedProxy, $entity)
2372 ) {
2373 // We will hydrate the given un-managed proxy anyway:
2374 // continue work, but consider it the entity from now on
2375 $entity = $unmanagedProxy;
2376 }
2377 }
2378
2379 if ($this->isUninitializedObject($entity)) {
2380 $entity->__setInitialized(true);
2381 } else {
2382 if (
2383 ! isset($hints[Query::HINT_REFRESH])
2384 || (isset($hints[Query::HINT_REFRESH_ENTITY]) && $hints[Query::HINT_REFRESH_ENTITY] !== $entity)
2385 ) {
2386 return $entity;
2387 }
2388 }
2389
2390 $this->originalEntityData[$oid] = $data;
2391 } else {
2392 $entity = $class->newInstance();
2393 $oid = spl_object_id($entity);
2394 $this->registerManaged($entity, $id, $data);
2395
2396 if (isset($hints[Query::HINT_READ_ONLY])) {
2397 $this->readOnlyObjects[$oid] = true;
2398 }
2399 }
2400
2401 foreach ($data as $field => $value) {
2402 if (isset($class->fieldMappings[$field])) {
2403 $class->reflFields[$field]->setValue($entity, $value);
2404 }
2405 }
2406
2407 // Loading the entity right here, if its in the eager loading map get rid of it there.
2408 unset($this->eagerLoadingEntities[$class->rootEntityName][$idHash]);
2409
2410 if (isset($this->eagerLoadingEntities[$class->rootEntityName]) && ! $this->eagerLoadingEntities[$class->rootEntityName]) {
2411 unset($this->eagerLoadingEntities[$class->rootEntityName]);
2412 }
2413
2414 foreach ($class->associationMappings as $field => $assoc) {
2415 // Check if the association is not among the fetch-joined associations already.
2416 if (isset($hints['fetchAlias'], $hints['fetched'][$hints['fetchAlias']][$field])) {
2417 continue;
2418 }
2419
2420 if (! isset($hints['fetchMode'][$class->name][$field])) {
2421 $hints['fetchMode'][$class->name][$field] = $assoc->fetch;
2422 }
2423
2424 $targetClass = $this->em->getClassMetadata($assoc->targetEntity);
2425
2426 switch (true) {
2427 case $assoc->isToOne():
2428 if (! $assoc->isOwningSide()) {
2429 // use the given entity association
2430 if (isset($data[$field]) && is_object($data[$field]) && isset($this->entityStates[spl_object_id($data[$field])])) {
2431 $this->originalEntityData[$oid][$field] = $data[$field];
2432
2433 $class->reflFields[$field]->setValue($entity, $data[$field]);
2434 $targetClass->reflFields[$assoc->mappedBy]->setValue($data[$field], $entity);
2435
2436 continue 2;
2437 }
2438
2439 // Inverse side of x-to-one can never be lazy
2440 $class->reflFields[$field]->setValue($entity, $this->getEntityPersister($assoc->targetEntity)->loadOneToOneEntity($assoc, $entity));
2441
2442 continue 2;
2443 }
2444
2445 // use the entity association
2446 if (isset($data[$field]) && is_object($data[$field]) && isset($this->entityStates[spl_object_id($data[$field])])) {
2447 $class->reflFields[$field]->setValue($entity, $data[$field]);
2448 $this->originalEntityData[$oid][$field] = $data[$field];
2449
2450 break;
2451 }
2452
2453 $associatedId = [];
2454
2455 assert($assoc->isToOneOwningSide());
2456 // TODO: Is this even computed right in all cases of composite keys?
2457 foreach ($assoc->targetToSourceKeyColumns as $targetColumn => $srcColumn) {
2458 $joinColumnValue = $data[$srcColumn] ?? null;
2459
2460 if ($joinColumnValue !== null) {
2461 if ($joinColumnValue instanceof BackedEnum) {
2462 $joinColumnValue = $joinColumnValue->value;
2463 }
2464
2465 if ($targetClass->containsForeignIdentifier) {
2466 $associatedId[$targetClass->getFieldForColumn($targetColumn)] = $joinColumnValue;
2467 } else {
2468 $associatedId[$targetClass->fieldNames[$targetColumn]] = $joinColumnValue;
2469 }
2470 } elseif (in_array($targetClass->getFieldForColumn($targetColumn), $targetClass->identifier, true)) {
2471 // the missing key is part of target's entity primary key
2472 $associatedId = [];
2473 break;
2474 }
2475 }
2476
2477 if (! $associatedId) {
2478 // Foreign key is NULL
2479 $class->reflFields[$field]->setValue($entity, null);
2480 $this->originalEntityData[$oid][$field] = null;
2481
2482 break;
2483 }
2484
2485 // Foreign key is set
2486 // Check identity map first
2487 // FIXME: Can break easily with composite keys if join column values are in
2488 // wrong order. The correct order is the one in ClassMetadata#identifier.
2489 $relatedIdHash = self::getIdHashByIdentifier($associatedId);
2490
2491 switch (true) {
2492 case isset($this->identityMap[$targetClass->rootEntityName][$relatedIdHash]):
2493 $newValue = $this->identityMap[$targetClass->rootEntityName][$relatedIdHash];
2494
2495 // If this is an uninitialized proxy, we are deferring eager loads,
2496 // this association is marked as eager fetch, and its an uninitialized proxy (wtf!)
2497 // then we can append this entity for eager loading!
2498 if (
2499 $hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER &&
2500 isset($hints[self::HINT_DEFEREAGERLOAD]) &&
2501 ! $targetClass->isIdentifierComposite &&
2502 $this->isUninitializedObject($newValue)
2503 ) {
2504 $this->eagerLoadingEntities[$targetClass->rootEntityName][$relatedIdHash] = current($associatedId);
2505 }
2506
2507 break;
2508
2509 case $targetClass->subClasses:
2510 // If it might be a subtype, it can not be lazy. There isn't even
2511 // a way to solve this with deferred eager loading, which means putting
2512 // an entity with subclasses at a *-to-one location is really bad! (performance-wise)
2513 $newValue = $this->getEntityPersister($assoc->targetEntity)->loadOneToOneEntity($assoc, $entity, $associatedId);
2514 break;
2515
2516 default:
2517 $normalizedAssociatedId = $this->normalizeIdentifier($targetClass, $associatedId);
2518
2519 switch (true) {
2520 // We are negating the condition here. Other cases will assume it is valid!
2521 case $hints['fetchMode'][$class->name][$field] !== ClassMetadata::FETCH_EAGER:
2522 $newValue = $this->em->getProxyFactory()->getProxy($assoc->targetEntity, $normalizedAssociatedId);
2523 $this->registerManaged($newValue, $associatedId, []);
2524 break;
2525
2526 // Deferred eager load only works for single identifier classes
2527 case isset($hints[self::HINT_DEFEREAGERLOAD]) &&
2528 $hints[self::HINT_DEFEREAGERLOAD] &&
2529 ! $targetClass->isIdentifierComposite:
2530 // TODO: Is there a faster approach?
2531 $this->eagerLoadingEntities[$targetClass->rootEntityName][$relatedIdHash] = current($normalizedAssociatedId);
2532
2533 $newValue = $this->em->getProxyFactory()->getProxy($assoc->targetEntity, $normalizedAssociatedId);
2534 $this->registerManaged($newValue, $associatedId, []);
2535 break;
2536
2537 default:
2538 // TODO: This is very imperformant, ignore it?
2539 $newValue = $this->em->find($assoc->targetEntity, $normalizedAssociatedId);
2540 break;
2541 }
2542 }
2543
2544 $this->originalEntityData[$oid][$field] = $newValue;
2545 $class->reflFields[$field]->setValue($entity, $newValue);
2546
2547 if ($assoc->inversedBy !== null && $assoc->isOneToOne() && $newValue !== null) {
2548 $inverseAssoc = $targetClass->associationMappings[$assoc->inversedBy];
2549 $targetClass->reflFields[$inverseAssoc->fieldName]->setValue($newValue, $entity);
2550 }
2551
2552 break;
2553
2554 default:
2555 assert($assoc->isToMany());
2556 // Ignore if its a cached collection
2557 if (isset($hints[Query::HINT_CACHE_ENABLED]) && $class->getFieldValue($entity, $field) instanceof PersistentCollection) {
2558 break;
2559 }
2560
2561 // use the given collection
2562 if (isset($data[$field]) && $data[$field] instanceof PersistentCollection) {
2563 $data[$field]->setOwner($entity, $assoc);
2564
2565 $class->reflFields[$field]->setValue($entity, $data[$field]);
2566 $this->originalEntityData[$oid][$field] = $data[$field];
2567
2568 break;
2569 }
2570
2571 // Inject collection
2572 $pColl = new PersistentCollection($this->em, $targetClass, new ArrayCollection());
2573 $pColl->setOwner($entity, $assoc);
2574 $pColl->setInitialized(false);
2575
2576 $reflField = $class->reflFields[$field];
2577 $reflField->setValue($entity, $pColl);
2578
2579 if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
2580 $isIteration = isset($hints[Query::HINT_INTERNAL_ITERATION]) && $hints[Query::HINT_INTERNAL_ITERATION];
2581 if (! $isIteration && $assoc->isOneToMany() && ! $targetClass->isIdentifierComposite && ! $assoc->isIndexed()) {
2582 $this->scheduleCollectionForBatchLoading($pColl, $class);
2583 } else {
2584 $this->loadCollection($pColl);
2585 $pColl->takeSnapshot();
2586 }
2587 }
2588
2589 $this->originalEntityData[$oid][$field] = $pColl;
2590 break;
2591 }
2592 }
2593
2594 // defer invoking of postLoad event to hydration complete step
2595 $this->hydrationCompleteHandler->deferPostLoadInvoking($class, $entity);
2596
2597 return $entity;
2598 }
2599
2600 public function triggerEagerLoads(): void
2601 {
2602 if (! $this->eagerLoadingEntities && ! $this->eagerLoadingCollections) {
2603 return;
2604 }
2605
2606 // avoid infinite recursion
2607 $eagerLoadingEntities = $this->eagerLoadingEntities;
2608 $this->eagerLoadingEntities = [];
2609
2610 foreach ($eagerLoadingEntities as $entityName => $ids) {
2611 if (! $ids) {
2612 continue;
2613 }
2614
2615 $class = $this->em->getClassMetadata($entityName);
2616 $batches = array_chunk($ids, $this->em->getConfiguration()->getEagerFetchBatchSize());
2617
2618 foreach ($batches as $batchedIds) {
2619 $this->getEntityPersister($entityName)->loadAll(
2620 array_combine($class->identifier, [$batchedIds]),
2621 );
2622 }
2623 }
2624
2625 $eagerLoadingCollections = $this->eagerLoadingCollections; // avoid recursion
2626 $this->eagerLoadingCollections = [];
2627
2628 foreach ($eagerLoadingCollections as $group) {
2629 $this->eagerLoadCollections($group['items'], $group['mapping']);
2630 }
2631 }
2632
2633 /**
2634 * Load all data into the given collections, according to the specified mapping
2635 *
2636 * @param PersistentCollection[] $collections
2637 */
2638 private function eagerLoadCollections(array $collections, ToManyInverseSideMapping $mapping): void
2639 {
2640 $targetEntity = $mapping->targetEntity;
2641 $class = $this->em->getClassMetadata($mapping->sourceEntity);
2642 $mappedBy = $mapping->mappedBy;
2643
2644 $batches = array_chunk($collections, $this->em->getConfiguration()->getEagerFetchBatchSize(), true);
2645
2646 foreach ($batches as $collectionBatch) {
2647 $entities = [];
2648
2649 foreach ($collectionBatch as $collection) {
2650 $entities[] = $collection->getOwner();
2651 }
2652
2653 $found = $this->getEntityPersister($targetEntity)->loadAll([$mappedBy => $entities], $mapping->orderBy);
2654
2655 $targetClass = $this->em->getClassMetadata($targetEntity);
2656 $targetProperty = $targetClass->getReflectionProperty($mappedBy);
2657 assert($targetProperty !== null);
2658
2659 foreach ($found as $targetValue) {
2660 $sourceEntity = $targetProperty->getValue($targetValue);
2661
2662 if ($sourceEntity === null && isset($targetClass->associationMappings[$mappedBy]->joinColumns)) {
2663 // case where the hydration $targetValue itself has not yet fully completed, for example
2664 // in case a bi-directional association is being hydrated and deferring eager loading is
2665 // not possible due to subclassing.
2666 $data = $this->getOriginalEntityData($targetValue);
2667 $id = [];
2668 foreach ($targetClass->associationMappings[$mappedBy]->joinColumns as $joinColumn) {
2669 $id[] = $data[$joinColumn->name];
2670 }
2671 } else {
2672 $id = $this->identifierFlattener->flattenIdentifier($class, $class->getIdentifierValues($sourceEntity));
2673 }
2674
2675 $idHash = implode(' ', $id);
2676
2677 if ($mapping->indexBy !== null) {
2678 $indexByProperty = $targetClass->getReflectionProperty($mapping->indexBy);
2679 assert($indexByProperty !== null);
2680 $collectionBatch[$idHash]->hydrateSet($indexByProperty->getValue($targetValue), $targetValue);
2681 } else {
2682 $collectionBatch[$idHash]->add($targetValue);
2683 }
2684 }
2685 }
2686
2687 foreach ($collections as $association) {
2688 $association->setInitialized(true);
2689 $association->takeSnapshot();
2690 }
2691 }
2692
2693 /**
2694 * Initializes (loads) an uninitialized persistent collection of an entity.
2695 *
2696 * @todo Maybe later move to EntityManager#initialize($proxyOrCollection). See DDC-733.
2697 */
2698 public function loadCollection(PersistentCollection $collection): void
2699 {
2700 $assoc = $collection->getMapping();
2701 $persister = $this->getEntityPersister($assoc->targetEntity);
2702
2703 switch ($assoc->type()) {
2704 case ClassMetadata::ONE_TO_MANY:
2705 $persister->loadOneToManyCollection($assoc, $collection->getOwner(), $collection);
2706 break;
2707
2708 case ClassMetadata::MANY_TO_MANY:
2709 $persister->loadManyToManyCollection($assoc, $collection->getOwner(), $collection);
2710 break;
2711 }
2712
2713 $collection->setInitialized(true);
2714 }
2715
2716 /**
2717 * Schedule this collection for batch loading at the end of the UnitOfWork
2718 */
2719 private function scheduleCollectionForBatchLoading(PersistentCollection $collection, ClassMetadata $sourceClass): void
2720 {
2721 $mapping = $collection->getMapping();
2722 $name = $mapping->sourceEntity . '#' . $mapping->fieldName;
2723
2724 if (! isset($this->eagerLoadingCollections[$name])) {
2725 $this->eagerLoadingCollections[$name] = [
2726 'items' => [],
2727 'mapping' => $mapping,
2728 ];
2729 }
2730
2731 $owner = $collection->getOwner();
2732 assert($owner !== null);
2733
2734 $id = $this->identifierFlattener->flattenIdentifier(
2735 $sourceClass,
2736 $sourceClass->getIdentifierValues($owner),
2737 );
2738 $idHash = implode(' ', $id);
2739
2740 $this->eagerLoadingCollections[$name]['items'][$idHash] = $collection;
2741 }
2742
2743 /**
2744 * Gets the identity map of the UnitOfWork.
2745 *
2746 * @psalm-return array<class-string, array<string, object>>
2747 */
2748 public function getIdentityMap(): array
2749 {
2750 return $this->identityMap;
2751 }
2752
2753 /**
2754 * Gets the original data of an entity. The original data is the data that was
2755 * present at the time the entity was reconstituted from the database.
2756 *
2757 * @psalm-return array<string, mixed>
2758 */
2759 public function getOriginalEntityData(object $entity): array
2760 {
2761 $oid = spl_object_id($entity);
2762
2763 return $this->originalEntityData[$oid] ?? [];
2764 }
2765
2766 /**
2767 * @param mixed[] $data
2768 *
2769 * @ignore
2770 */
2771 public function setOriginalEntityData(object $entity, array $data): void
2772 {
2773 $this->originalEntityData[spl_object_id($entity)] = $data;
2774 }
2775
2776 /**
2777 * INTERNAL:
2778 * Sets a property value of the original data array of an entity.
2779 *
2780 * @ignore
2781 */
2782 public function setOriginalEntityProperty(int $oid, string $property, mixed $value): void
2783 {
2784 $this->originalEntityData[$oid][$property] = $value;
2785 }
2786
2787 /**
2788 * Gets the identifier of an entity.
2789 * The returned value is always an array of identifier values. If the entity
2790 * has a composite identifier then the identifier values are in the same
2791 * order as the identifier field names as returned by ClassMetadata#getIdentifierFieldNames().
2792 *
2793 * @return mixed[] The identifier values.
2794 */
2795 public function getEntityIdentifier(object $entity): array
2796 {
2797 return $this->entityIdentifiers[spl_object_id($entity)]
2798 ?? throw EntityNotFoundException::noIdentifierFound(get_debug_type($entity));
2799 }
2800
2801 /**
2802 * Processes an entity instance to extract their identifier values.
2803 *
2804 * @return mixed A scalar value.
2805 *
2806 * @throws ORMInvalidArgumentException
2807 */
2808 public function getSingleIdentifierValue(object $entity): mixed
2809 {
2810 $class = $this->em->getClassMetadata($entity::class);
2811
2812 if ($class->isIdentifierComposite) {
2813 throw ORMInvalidArgumentException::invalidCompositeIdentifier();
2814 }
2815
2816 $values = $this->isInIdentityMap($entity)
2817 ? $this->getEntityIdentifier($entity)
2818 : $class->getIdentifierValues($entity);
2819
2820 return $values[$class->identifier[0]] ?? null;
2821 }
2822
2823 /**
2824 * Tries to find an entity with the given identifier in the identity map of
2825 * this UnitOfWork.
2826 *
2827 * @param mixed $id The entity identifier to look for.
2828 * @param string $rootClassName The name of the root class of the mapped entity hierarchy.
2829 * @psalm-param class-string $rootClassName
2830 *
2831 * @return object|false Returns the entity with the specified identifier if it exists in
2832 * this UnitOfWork, FALSE otherwise.
2833 */
2834 public function tryGetById(mixed $id, string $rootClassName): object|false
2835 {
2836 $idHash = self::getIdHashByIdentifier((array) $id);
2837
2838 return $this->identityMap[$rootClassName][$idHash] ?? false;
2839 }
2840
2841 /**
2842 * Schedules an entity for dirty-checking at commit-time.
2843 *
2844 * @todo Rename: scheduleForSynchronization
2845 */
2846 public function scheduleForDirtyCheck(object $entity): void
2847 {
2848 $rootClassName = $this->em->getClassMetadata($entity::class)->rootEntityName;
2849
2850 $this->scheduledForSynchronization[$rootClassName][spl_object_id($entity)] = $entity;
2851 }
2852
2853 /**
2854 * Checks whether the UnitOfWork has any pending insertions.
2855 */
2856 public function hasPendingInsertions(): bool
2857 {
2858 return ! empty($this->entityInsertions);
2859 }
2860
2861 /**
2862 * Calculates the size of the UnitOfWork. The size of the UnitOfWork is the
2863 * number of entities in the identity map.
2864 */
2865 public function size(): int
2866 {
2867 return array_sum(array_map('count', $this->identityMap));
2868 }
2869
2870 /**
2871 * Gets the EntityPersister for an Entity.
2872 *
2873 * @psalm-param class-string $entityName
2874 */
2875 public function getEntityPersister(string $entityName): EntityPersister
2876 {
2877 if (isset($this->persisters[$entityName])) {
2878 return $this->persisters[$entityName];
2879 }
2880
2881 $class = $this->em->getClassMetadata($entityName);
2882
2883 $persister = match (true) {
2884 $class->isInheritanceTypeNone() => new BasicEntityPersister($this->em, $class),
2885 $class->isInheritanceTypeSingleTable() => new SingleTablePersister($this->em, $class),
2886 $class->isInheritanceTypeJoined() => new JoinedSubclassPersister($this->em, $class),
2887 default => throw new RuntimeException('No persister found for entity.'),
2888 };
2889
2890 if ($this->hasCache && $class->cache !== null) {
2891 $persister = $this->em->getConfiguration()
2892 ->getSecondLevelCacheConfiguration()
2893 ->getCacheFactory()
2894 ->buildCachedEntityPersister($this->em, $persister, $class);
2895 }
2896
2897 $this->persisters[$entityName] = $persister;
2898
2899 return $this->persisters[$entityName];
2900 }
2901
2902 /** Gets a collection persister for a collection-valued association. */
2903 public function getCollectionPersister(AssociationMapping $association): CollectionPersister
2904 {
2905 $role = isset($association->cache)
2906 ? $association->sourceEntity . '::' . $association->fieldName
2907 : $association->type();
2908
2909 if (isset($this->collectionPersisters[$role])) {
2910 return $this->collectionPersisters[$role];
2911 }
2912
2913 $persister = $association->type() === ClassMetadata::ONE_TO_MANY
2914 ? new OneToManyPersister($this->em)
2915 : new ManyToManyPersister($this->em);
2916
2917 if ($this->hasCache && isset($association->cache)) {
2918 $persister = $this->em->getConfiguration()
2919 ->getSecondLevelCacheConfiguration()
2920 ->getCacheFactory()
2921 ->buildCachedCollectionPersister($this->em, $persister, $association);
2922 }
2923
2924 $this->collectionPersisters[$role] = $persister;
2925
2926 return $this->collectionPersisters[$role];
2927 }
2928
2929 /**
2930 * INTERNAL:
2931 * Registers an entity as managed.
2932 *
2933 * @param mixed[] $id The identifier values.
2934 * @param mixed[] $data The original entity data.
2935 */
2936 public function registerManaged(object $entity, array $id, array $data): void
2937 {
2938 $oid = spl_object_id($entity);
2939
2940 $this->entityIdentifiers[$oid] = $id;
2941 $this->entityStates[$oid] = self::STATE_MANAGED;
2942 $this->originalEntityData[$oid] = $data;
2943
2944 $this->addToIdentityMap($entity);
2945 }
2946
2947 /* PropertyChangedListener implementation */
2948
2949 /**
2950 * Notifies this UnitOfWork of a property change in an entity.
2951 *
2952 * {@inheritDoc}
2953 */
2954 public function propertyChanged(object $sender, string $propertyName, mixed $oldValue, mixed $newValue): void
2955 {
2956 $oid = spl_object_id($sender);
2957 $class = $this->em->getClassMetadata($sender::class);
2958
2959 $isAssocField = isset($class->associationMappings[$propertyName]);
2960
2961 if (! $isAssocField && ! isset($class->fieldMappings[$propertyName])) {
2962 return; // ignore non-persistent fields
2963 }
2964
2965 // Update changeset and mark entity for synchronization
2966 $this->entityChangeSets[$oid][$propertyName] = [$oldValue, $newValue];
2967
2968 if (! isset($this->scheduledForSynchronization[$class->rootEntityName][$oid])) {
2969 $this->scheduleForDirtyCheck($sender);
2970 }
2971 }
2972
2973 /**
2974 * Gets the currently scheduled entity insertions in this UnitOfWork.
2975 *
2976 * @psalm-return array<int, object>
2977 */
2978 public function getScheduledEntityInsertions(): array
2979 {
2980 return $this->entityInsertions;
2981 }
2982
2983 /**
2984 * Gets the currently scheduled entity updates in this UnitOfWork.
2985 *
2986 * @psalm-return array<int, object>
2987 */
2988 public function getScheduledEntityUpdates(): array
2989 {
2990 return $this->entityUpdates;
2991 }
2992
2993 /**
2994 * Gets the currently scheduled entity deletions in this UnitOfWork.
2995 *
2996 * @psalm-return array<int, object>
2997 */
2998 public function getScheduledEntityDeletions(): array
2999 {
3000 return $this->entityDeletions;
3001 }
3002
3003 /**
3004 * Gets the currently scheduled complete collection deletions
3005 *
3006 * @psalm-return array<int, PersistentCollection<array-key, object>>
3007 */
3008 public function getScheduledCollectionDeletions(): array
3009 {
3010 return $this->collectionDeletions;
3011 }
3012
3013 /**
3014 * Gets the currently scheduled collection inserts, updates and deletes.
3015 *
3016 * @psalm-return array<int, PersistentCollection<array-key, object>>
3017 */
3018 public function getScheduledCollectionUpdates(): array
3019 {
3020 return $this->collectionUpdates;
3021 }
3022
3023 /**
3024 * Helper method to initialize a lazy loading proxy or persistent collection.
3025 */
3026 public function initializeObject(object $obj): void
3027 {
3028 if ($obj instanceof InternalProxy) {
3029 $obj->__load();
3030
3031 return;
3032 }
3033
3034 if ($obj instanceof PersistentCollection) {
3035 $obj->initialize();
3036 }
3037 }
3038
3039 /**
3040 * Tests if a value is an uninitialized entity.
3041 *
3042 * @psalm-assert-if-true InternalProxy $obj
3043 */
3044 public function isUninitializedObject(mixed $obj): bool
3045 {
3046 return $obj instanceof InternalProxy && ! $obj->__isInitialized();
3047 }
3048
3049 /**
3050 * Helper method to show an object as string.
3051 */
3052 private static function objToStr(object $obj): string
3053 {
3054 return $obj instanceof Stringable ? (string) $obj : get_debug_type($obj) . '@' . spl_object_id($obj);
3055 }
3056
3057 /**
3058 * Marks an entity as read-only so that it will not be considered for updates during UnitOfWork#commit().
3059 *
3060 * This operation cannot be undone as some parts of the UnitOfWork now keep gathering information
3061 * on this object that might be necessary to perform a correct update.
3062 *
3063 * @throws ORMInvalidArgumentException
3064 */
3065 public function markReadOnly(object $object): void
3066 {
3067 if (! $this->isInIdentityMap($object)) {
3068 throw ORMInvalidArgumentException::readOnlyRequiresManagedEntity($object);
3069 }
3070
3071 $this->readOnlyObjects[spl_object_id($object)] = true;
3072 }
3073
3074 /**
3075 * Is this entity read only?
3076 *
3077 * @throws ORMInvalidArgumentException
3078 */
3079 public function isReadOnly(object $object): bool
3080 {
3081 return isset($this->readOnlyObjects[spl_object_id($object)]);
3082 }
3083
3084 /**
3085 * Perform whatever processing is encapsulated here after completion of the transaction.
3086 */
3087 private function afterTransactionComplete(): void
3088 {
3089 $this->performCallbackOnCachedPersister(static function (CachedPersister $persister): void {
3090 $persister->afterTransactionComplete();
3091 });
3092 }
3093
3094 /**
3095 * Perform whatever processing is encapsulated here after completion of the rolled-back.
3096 */
3097 private function afterTransactionRolledBack(): void
3098 {
3099 $this->performCallbackOnCachedPersister(static function (CachedPersister $persister): void {
3100 $persister->afterTransactionRolledBack();
3101 });
3102 }
3103
3104 /**
3105 * Performs an action after the transaction.
3106 */
3107 private function performCallbackOnCachedPersister(callable $callback): void
3108 {
3109 if (! $this->hasCache) {
3110 return;
3111 }
3112
3113 foreach ([...$this->persisters, ...$this->collectionPersisters] as $persister) {
3114 if ($persister instanceof CachedPersister) {
3115 $callback($persister);
3116 }
3117 }
3118 }
3119
3120 private function dispatchOnFlushEvent(): void
3121 {
3122 if ($this->evm->hasListeners(Events::onFlush)) {
3123 $this->evm->dispatchEvent(Events::onFlush, new OnFlushEventArgs($this->em));
3124 }
3125 }
3126
3127 private function dispatchPostFlushEvent(): void
3128 {
3129 if ($this->evm->hasListeners(Events::postFlush)) {
3130 $this->evm->dispatchEvent(Events::postFlush, new PostFlushEventArgs($this->em));
3131 }
3132 }
3133
3134 /**
3135 * Verifies if two given entities actually are the same based on identifier comparison
3136 */
3137 private function isIdentifierEquals(object $entity1, object $entity2): bool
3138 {
3139 if ($entity1 === $entity2) {
3140 return true;
3141 }
3142
3143 $class = $this->em->getClassMetadata($entity1::class);
3144
3145 if ($class !== $this->em->getClassMetadata($entity2::class)) {
3146 return false;
3147 }
3148
3149 $oid1 = spl_object_id($entity1);
3150 $oid2 = spl_object_id($entity2);
3151
3152 $id1 = $this->entityIdentifiers[$oid1] ?? $this->identifierFlattener->flattenIdentifier($class, $class->getIdentifierValues($entity1));
3153 $id2 = $this->entityIdentifiers[$oid2] ?? $this->identifierFlattener->flattenIdentifier($class, $class->getIdentifierValues($entity2));
3154
3155 return $id1 === $id2 || self::getIdHashByIdentifier($id1) === self::getIdHashByIdentifier($id2);
3156 }
3157
3158 /** @throws ORMInvalidArgumentException */
3159 private function assertThatThereAreNoUnintentionallyNonPersistedAssociations(): void
3160 {
3161 $entitiesNeedingCascadePersist = array_diff_key($this->nonCascadedNewDetectedEntities, $this->entityInsertions);
3162
3163 $this->nonCascadedNewDetectedEntities = [];
3164
3165 if ($entitiesNeedingCascadePersist) {
3166 throw ORMInvalidArgumentException::newEntitiesFoundThroughRelationships(
3167 array_values($entitiesNeedingCascadePersist),
3168 );
3169 }
3170 }
3171
3172 /**
3173 * This method called by hydrators, and indicates that hydrator totally completed current hydration cycle.
3174 * Unit of work able to fire deferred events, related to loading events here.
3175 *
3176 * @internal should be called internally from object hydrators
3177 */
3178 public function hydrationComplete(): void
3179 {
3180 $this->hydrationCompleteHandler->hydrationComplete();
3181 }
3182
3183 /** @throws MappingException if the entity has more than a single identifier. */
3184 private function convertSingleFieldIdentifierToPHPValue(ClassMetadata $class, mixed $identifierValue): mixed
3185 {
3186 return $this->em->getConnection()->convertToPHPValue(
3187 $identifierValue,
3188 $class->getTypeOfField($class->getSingleIdentifierFieldName()),
3189 );
3190 }
3191
3192 /**
3193 * Given a flat identifier, this method will produce another flat identifier, but with all
3194 * association fields that are mapped as identifiers replaced by entity references, recursively.
3195 *
3196 * @param mixed[] $flatIdentifier
3197 *
3198 * @return array<string, mixed>
3199 */
3200 private function normalizeIdentifier(ClassMetadata $targetClass, array $flatIdentifier): array
3201 {
3202 $normalizedAssociatedId = [];
3203
3204 foreach ($targetClass->getIdentifierFieldNames() as $name) {
3205 if (! array_key_exists($name, $flatIdentifier)) {
3206 continue;
3207 }
3208
3209 if (! $targetClass->isSingleValuedAssociation($name)) {
3210 $normalizedAssociatedId[$name] = $flatIdentifier[$name];
3211 continue;
3212 }
3213
3214 $targetIdMetadata = $this->em->getClassMetadata($targetClass->getAssociationTargetClass($name));
3215
3216 // Note: the ORM prevents using an entity with a composite identifier as an identifier association
3217 // therefore, reset($targetIdMetadata->identifier) is always correct
3218 $normalizedAssociatedId[$name] = $this->em->getReference(
3219 $targetIdMetadata->getName(),
3220 $this->normalizeIdentifier(
3221 $targetIdMetadata,
3222 [(string) reset($targetIdMetadata->identifier) => $flatIdentifier[$name]],
3223 ),
3224 );
3225 }
3226
3227 return $normalizedAssociatedId;
3228 }
3229
3230 /**
3231 * Assign a post-insert generated ID to an entity
3232 *
3233 * This is used by EntityPersisters after they inserted entities into the database.
3234 * It will place the assigned ID values in the entity's fields and start tracking
3235 * the entity in the identity map.
3236 */
3237 final public function assignPostInsertId(object $entity, mixed $generatedId): void
3238 {
3239 $class = $this->em->getClassMetadata($entity::class);
3240 $idField = $class->getSingleIdentifierFieldName();
3241 $idValue = $this->convertSingleFieldIdentifierToPHPValue($class, $generatedId);
3242 $oid = spl_object_id($entity);
3243
3244 $class->reflFields[$idField]->setValue($entity, $idValue);
3245
3246 $this->entityIdentifiers[$oid] = [$idField => $idValue];
3247 $this->entityStates[$oid] = self::STATE_MANAGED;
3248 $this->originalEntityData[$oid][$idField] = $idValue;
3249
3250 $this->addToIdentityMap($entity);
3251 }
3252}