diff options
Diffstat (limited to 'vendor/doctrine/orm/src/UnitOfWork.php')
-rw-r--r-- | vendor/doctrine/orm/src/UnitOfWork.php | 3252 |
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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM; | ||
6 | |||
7 | use BackedEnum; | ||
8 | use DateTimeInterface; | ||
9 | use Doctrine\Common\Collections\ArrayCollection; | ||
10 | use Doctrine\Common\Collections\Collection; | ||
11 | use Doctrine\Common\EventManager; | ||
12 | use Doctrine\DBAL; | ||
13 | use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; | ||
14 | use Doctrine\DBAL\LockMode; | ||
15 | use Doctrine\ORM\Cache\Persister\CachedPersister; | ||
16 | use Doctrine\ORM\Event\ListenersInvoker; | ||
17 | use Doctrine\ORM\Event\OnClearEventArgs; | ||
18 | use Doctrine\ORM\Event\OnFlushEventArgs; | ||
19 | use Doctrine\ORM\Event\PostFlushEventArgs; | ||
20 | use Doctrine\ORM\Event\PostPersistEventArgs; | ||
21 | use Doctrine\ORM\Event\PostRemoveEventArgs; | ||
22 | use Doctrine\ORM\Event\PostUpdateEventArgs; | ||
23 | use Doctrine\ORM\Event\PreFlushEventArgs; | ||
24 | use Doctrine\ORM\Event\PrePersistEventArgs; | ||
25 | use Doctrine\ORM\Event\PreRemoveEventArgs; | ||
26 | use Doctrine\ORM\Event\PreUpdateEventArgs; | ||
27 | use Doctrine\ORM\Exception\EntityIdentityCollisionException; | ||
28 | use Doctrine\ORM\Exception\ORMException; | ||
29 | use Doctrine\ORM\Exception\UnexpectedAssociationValue; | ||
30 | use Doctrine\ORM\Id\AssignedGenerator; | ||
31 | use Doctrine\ORM\Internal\HydrationCompleteHandler; | ||
32 | use Doctrine\ORM\Internal\StronglyConnectedComponents; | ||
33 | use Doctrine\ORM\Internal\TopologicalSort; | ||
34 | use Doctrine\ORM\Mapping\AssociationMapping; | ||
35 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
36 | use Doctrine\ORM\Mapping\MappingException; | ||
37 | use Doctrine\ORM\Mapping\ToManyInverseSideMapping; | ||
38 | use Doctrine\ORM\Persisters\Collection\CollectionPersister; | ||
39 | use Doctrine\ORM\Persisters\Collection\ManyToManyPersister; | ||
40 | use Doctrine\ORM\Persisters\Collection\OneToManyPersister; | ||
41 | use Doctrine\ORM\Persisters\Entity\BasicEntityPersister; | ||
42 | use Doctrine\ORM\Persisters\Entity\EntityPersister; | ||
43 | use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister; | ||
44 | use Doctrine\ORM\Persisters\Entity\SingleTablePersister; | ||
45 | use Doctrine\ORM\Proxy\InternalProxy; | ||
46 | use Doctrine\ORM\Utility\IdentifierFlattener; | ||
47 | use Doctrine\Persistence\PropertyChangedListener; | ||
48 | use Exception; | ||
49 | use InvalidArgumentException; | ||
50 | use RuntimeException; | ||
51 | use Stringable; | ||
52 | use Throwable; | ||
53 | use UnexpectedValueException; | ||
54 | |||
55 | use function array_chunk; | ||
56 | use function array_combine; | ||
57 | use function array_diff_key; | ||
58 | use function array_filter; | ||
59 | use function array_key_exists; | ||
60 | use function array_map; | ||
61 | use function array_sum; | ||
62 | use function array_values; | ||
63 | use function assert; | ||
64 | use function current; | ||
65 | use function get_debug_type; | ||
66 | use function implode; | ||
67 | use function in_array; | ||
68 | use function is_array; | ||
69 | use function is_object; | ||
70 | use function reset; | ||
71 | use function spl_object_id; | ||
72 | use function sprintf; | ||
73 | use 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 | */ | ||
82 | class 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 | } | ||