summaryrefslogtreecommitdiff
path: root/vendor/doctrine/orm/src/PersistentCollection.php
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/doctrine/orm/src/PersistentCollection.php')
-rw-r--r--vendor/doctrine/orm/src/PersistentCollection.php652
1 files changed, 652 insertions, 0 deletions
diff --git a/vendor/doctrine/orm/src/PersistentCollection.php b/vendor/doctrine/orm/src/PersistentCollection.php
new file mode 100644
index 0000000..d54d3d1
--- /dev/null
+++ b/vendor/doctrine/orm/src/PersistentCollection.php
@@ -0,0 +1,652 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM;
6
7use Doctrine\Common\Collections\AbstractLazyCollection;
8use Doctrine\Common\Collections\ArrayCollection;
9use Doctrine\Common\Collections\Collection;
10use Doctrine\Common\Collections\Criteria;
11use Doctrine\Common\Collections\Order;
12use Doctrine\Common\Collections\Selectable;
13use Doctrine\ORM\Mapping\AssociationMapping;
14use Doctrine\ORM\Mapping\ClassMetadata;
15use Doctrine\ORM\Mapping\ToManyAssociationMapping;
16use RuntimeException;
17use UnexpectedValueException;
18
19use function array_combine;
20use function array_diff_key;
21use function array_map;
22use function array_values;
23use function array_walk;
24use function assert;
25use function is_object;
26use function spl_object_id;
27use function strtoupper;
28
29/**
30 * A PersistentCollection represents a collection of elements that have persistent state.
31 *
32 * Collections of entities represent only the associations (links) to those entities.
33 * That means, if the collection is part of a many-many mapping and you remove
34 * entities from the collection, only the links in the relation table are removed (on flush).
35 * Similarly, if you remove entities from a collection that is part of a one-many
36 * mapping this will only result in the nulling out of the foreign keys on flush.
37 *
38 * @psalm-template TKey of array-key
39 * @psalm-template T
40 * @template-extends AbstractLazyCollection<TKey,T>
41 * @template-implements Selectable<TKey,T>
42 */
43final class PersistentCollection extends AbstractLazyCollection implements Selectable
44{
45 /**
46 * A snapshot of the collection at the moment it was fetched from the database.
47 * This is used to create a diff of the collection at commit time.
48 *
49 * @psalm-var array<string|int, mixed>
50 */
51 private array $snapshot = [];
52
53 /**
54 * The entity that owns this collection.
55 */
56 private object|null $owner = null;
57
58 /**
59 * The association mapping the collection belongs to.
60 * This is currently either a OneToManyMapping or a ManyToManyMapping.
61 *
62 * @var (AssociationMapping&ToManyAssociationMapping)|null
63 */
64 private AssociationMapping|null $association = null;
65
66 /**
67 * The name of the field on the target entities that points to the owner
68 * of the collection. This is only set if the association is bi-directional.
69 */
70 private string|null $backRefFieldName = null;
71
72 /**
73 * Whether the collection is dirty and needs to be synchronized with the database
74 * when the UnitOfWork that manages its persistent state commits.
75 */
76 private bool $isDirty = false;
77
78 /**
79 * Creates a new persistent collection.
80 *
81 * @param EntityManagerInterface $em The EntityManager the collection will be associated with.
82 * @param ClassMetadata $typeClass The class descriptor of the entity type of this collection.
83 * @psalm-param Collection<TKey, T>&Selectable<TKey, T> $collection The collection elements.
84 */
85 public function __construct(
86 private EntityManagerInterface|null $em,
87 private readonly ClassMetadata|null $typeClass,
88 Collection $collection,
89 ) {
90 $this->collection = $collection;
91 $this->initialized = true;
92 }
93
94 /**
95 * INTERNAL:
96 * Sets the collection's owning entity together with the AssociationMapping that
97 * describes the association between the owner and the elements of the collection.
98 */
99 public function setOwner(object $entity, AssociationMapping&ToManyAssociationMapping $assoc): void
100 {
101 $this->owner = $entity;
102 $this->association = $assoc;
103 $this->backRefFieldName = $assoc->isOwningSide() ? $assoc->inversedBy : $assoc->mappedBy;
104 }
105
106 /**
107 * INTERNAL:
108 * Gets the collection owner.
109 */
110 public function getOwner(): object|null
111 {
112 return $this->owner;
113 }
114
115 public function getTypeClass(): ClassMetadata
116 {
117 assert($this->typeClass !== null);
118
119 return $this->typeClass;
120 }
121
122 private function getUnitOfWork(): UnitOfWork
123 {
124 assert($this->em !== null);
125
126 return $this->em->getUnitOfWork();
127 }
128
129 /**
130 * INTERNAL:
131 * Adds an element to a collection during hydration. This will automatically
132 * complete bidirectional associations in the case of a one-to-many association.
133 */
134 public function hydrateAdd(mixed $element): void
135 {
136 $this->unwrap()->add($element);
137
138 // If _backRefFieldName is set and its a one-to-many association,
139 // we need to set the back reference.
140 if ($this->backRefFieldName && $this->getMapping()->isOneToMany()) {
141 assert($this->typeClass !== null);
142 // Set back reference to owner
143 $this->typeClass->reflFields[$this->backRefFieldName]->setValue(
144 $element,
145 $this->owner,
146 );
147
148 $this->getUnitOfWork()->setOriginalEntityProperty(
149 spl_object_id($element),
150 $this->backRefFieldName,
151 $this->owner,
152 );
153 }
154 }
155
156 /**
157 * INTERNAL:
158 * Sets a keyed element in the collection during hydration.
159 */
160 public function hydrateSet(mixed $key, mixed $element): void
161 {
162 $this->unwrap()->set($key, $element);
163
164 // If _backRefFieldName is set, then the association is bidirectional
165 // and we need to set the back reference.
166 if ($this->backRefFieldName && $this->getMapping()->isOneToMany()) {
167 assert($this->typeClass !== null);
168 // Set back reference to owner
169 $this->typeClass->reflFields[$this->backRefFieldName]->setValue(
170 $element,
171 $this->owner,
172 );
173 }
174 }
175
176 /**
177 * Initializes the collection by loading its contents from the database
178 * if the collection is not yet initialized.
179 */
180 public function initialize(): void
181 {
182 if ($this->initialized || ! $this->association) {
183 return;
184 }
185
186 $this->doInitialize();
187
188 $this->initialized = true;
189 }
190
191 /**
192 * INTERNAL:
193 * Tells this collection to take a snapshot of its current state.
194 */
195 public function takeSnapshot(): void
196 {
197 $this->snapshot = $this->unwrap()->toArray();
198 $this->isDirty = false;
199 }
200
201 /**
202 * INTERNAL:
203 * Returns the last snapshot of the elements in the collection.
204 *
205 * @psalm-return array<string|int, mixed> The last snapshot of the elements.
206 */
207 public function getSnapshot(): array
208 {
209 return $this->snapshot;
210 }
211
212 /**
213 * INTERNAL:
214 * getDeleteDiff
215 *
216 * @return mixed[]
217 */
218 public function getDeleteDiff(): array
219 {
220 $collectionItems = $this->unwrap()->toArray();
221
222 return array_values(array_diff_key(
223 array_combine(array_map('spl_object_id', $this->snapshot), $this->snapshot),
224 array_combine(array_map('spl_object_id', $collectionItems), $collectionItems),
225 ));
226 }
227
228 /**
229 * INTERNAL:
230 * getInsertDiff
231 *
232 * @return mixed[]
233 */
234 public function getInsertDiff(): array
235 {
236 $collectionItems = $this->unwrap()->toArray();
237
238 return array_values(array_diff_key(
239 array_combine(array_map('spl_object_id', $collectionItems), $collectionItems),
240 array_combine(array_map('spl_object_id', $this->snapshot), $this->snapshot),
241 ));
242 }
243
244 /** INTERNAL: Gets the association mapping of the collection. */
245 public function getMapping(): AssociationMapping&ToManyAssociationMapping
246 {
247 if ($this->association === null) {
248 throw new UnexpectedValueException('The underlying association mapping is null although it should not be');
249 }
250
251 return $this->association;
252 }
253
254 /**
255 * Marks this collection as changed/dirty.
256 */
257 private function changed(): void
258 {
259 if ($this->isDirty) {
260 return;
261 }
262
263 $this->isDirty = true;
264 }
265
266 /**
267 * Gets a boolean flag indicating whether this collection is dirty which means
268 * its state needs to be synchronized with the database.
269 */
270 public function isDirty(): bool
271 {
272 return $this->isDirty;
273 }
274
275 /**
276 * Sets a boolean flag, indicating whether this collection is dirty.
277 */
278 public function setDirty(bool $dirty): void
279 {
280 $this->isDirty = $dirty;
281 }
282
283 /**
284 * Sets the initialized flag of the collection, forcing it into that state.
285 */
286 public function setInitialized(bool $bool): void
287 {
288 $this->initialized = $bool;
289 }
290
291 public function remove(string|int $key): mixed
292 {
293 // TODO: If the keys are persistent as well (not yet implemented)
294 // and the collection is not initialized and orphanRemoval is
295 // not used we can issue a straight SQL delete/update on the
296 // association (table). Without initializing the collection.
297 $removed = parent::remove($key);
298
299 if (! $removed) {
300 return $removed;
301 }
302
303 $this->changed();
304
305 if (
306 $this->association !== null &&
307 $this->association->isToMany() &&
308 $this->owner &&
309 $this->getMapping()->orphanRemoval
310 ) {
311 $this->getUnitOfWork()->scheduleOrphanRemoval($removed);
312 }
313
314 return $removed;
315 }
316
317 public function removeElement(mixed $element): bool
318 {
319 $removed = parent::removeElement($element);
320
321 if (! $removed) {
322 return $removed;
323 }
324
325 $this->changed();
326
327 if (
328 $this->association !== null &&
329 $this->association->isToMany() &&
330 $this->owner &&
331 $this->getMapping()->orphanRemoval
332 ) {
333 $this->getUnitOfWork()->scheduleOrphanRemoval($element);
334 }
335
336 return $removed;
337 }
338
339 public function containsKey(mixed $key): bool
340 {
341 if (
342 ! $this->initialized && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY
343 && isset($this->getMapping()->indexBy)
344 ) {
345 $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping());
346
347 return $this->unwrap()->containsKey($key) || $persister->containsKey($this, $key);
348 }
349
350 return parent::containsKey($key);
351 }
352
353 public function contains(mixed $element): bool
354 {
355 if (! $this->initialized && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) {
356 $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping());
357
358 return $this->unwrap()->contains($element) || $persister->contains($this, $element);
359 }
360
361 return parent::contains($element);
362 }
363
364 public function get(string|int $key): mixed
365 {
366 if (
367 ! $this->initialized
368 && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY
369 && isset($this->getMapping()->indexBy)
370 ) {
371 assert($this->em !== null);
372 assert($this->typeClass !== null);
373 if (! $this->typeClass->isIdentifierComposite && $this->typeClass->isIdentifier($this->getMapping()->indexBy)) {
374 return $this->em->find($this->typeClass->name, $key);
375 }
376
377 return $this->getUnitOfWork()->getCollectionPersister($this->getMapping())->get($this, $key);
378 }
379
380 return parent::get($key);
381 }
382
383 public function count(): int
384 {
385 if (! $this->initialized && $this->association !== null && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) {
386 $persister = $this->getUnitOfWork()->getCollectionPersister($this->association);
387
388 return $persister->count($this) + ($this->isDirty ? $this->unwrap()->count() : 0);
389 }
390
391 return parent::count();
392 }
393
394 public function set(string|int $key, mixed $value): void
395 {
396 parent::set($key, $value);
397
398 $this->changed();
399
400 if (is_object($value) && $this->em) {
401 $this->getUnitOfWork()->cancelOrphanRemoval($value);
402 }
403 }
404
405 public function add(mixed $value): bool
406 {
407 $this->unwrap()->add($value);
408
409 $this->changed();
410
411 if (is_object($value) && $this->em) {
412 $this->getUnitOfWork()->cancelOrphanRemoval($value);
413 }
414
415 return true;
416 }
417
418 public function offsetExists(mixed $offset): bool
419 {
420 return $this->containsKey($offset);
421 }
422
423 public function offsetGet(mixed $offset): mixed
424 {
425 return $this->get($offset);
426 }
427
428 public function offsetSet(mixed $offset, mixed $value): void
429 {
430 if (! isset($offset)) {
431 $this->add($value);
432
433 return;
434 }
435
436 $this->set($offset, $value);
437 }
438
439 public function offsetUnset(mixed $offset): void
440 {
441 $this->remove($offset);
442 }
443
444 public function isEmpty(): bool
445 {
446 return $this->unwrap()->isEmpty() && $this->count() === 0;
447 }
448
449 public function clear(): void
450 {
451 if ($this->initialized && $this->isEmpty()) {
452 $this->unwrap()->clear();
453
454 return;
455 }
456
457 $uow = $this->getUnitOfWork();
458 $association = $this->getMapping();
459
460 if (
461 $association->isToMany() &&
462 $association->orphanRemoval &&
463 $this->owner
464 ) {
465 // we need to initialize here, as orphan removal acts like implicit cascadeRemove,
466 // hence for event listeners we need the objects in memory.
467 $this->initialize();
468
469 foreach ($this->unwrap() as $element) {
470 $uow->scheduleOrphanRemoval($element);
471 }
472 }
473
474 $this->unwrap()->clear();
475
476 $this->initialized = true; // direct call, {@link initialize()} is too expensive
477
478 if ($association->isOwningSide() && $this->owner) {
479 $this->changed();
480
481 $uow->scheduleCollectionDeletion($this);
482
483 $this->takeSnapshot();
484 }
485 }
486
487 /**
488 * Called by PHP when this collection is serialized. Ensures that only the
489 * elements are properly serialized.
490 *
491 * Internal note: Tried to implement Serializable first but that did not work well
492 * with circular references. This solution seems simpler and works well.
493 *
494 * @return string[]
495 * @psalm-return array{0: string, 1: string}
496 */
497 public function __sleep(): array
498 {
499 return ['collection', 'initialized'];
500 }
501
502 public function __wakeup(): void
503 {
504 $this->em = null;
505 }
506
507 /**
508 * Extracts a slice of $length elements starting at position $offset from the Collection.
509 *
510 * If $length is null it returns all elements from $offset to the end of the Collection.
511 * Keys have to be preserved by this method. Calling this method will only return the
512 * selected slice and NOT change the elements contained in the collection slice is called on.
513 *
514 * @return mixed[]
515 * @psalm-return array<TKey,T>
516 */
517 public function slice(int $offset, int|null $length = null): array
518 {
519 if (! $this->initialized && ! $this->isDirty && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) {
520 $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping());
521
522 return $persister->slice($this, $offset, $length);
523 }
524
525 return parent::slice($offset, $length);
526 }
527
528 /**
529 * Cleans up internal state of cloned persistent collection.
530 *
531 * The following problems have to be prevented:
532 * 1. Added entities are added to old PC
533 * 2. New collection is not dirty, if reused on other entity nothing
534 * changes.
535 * 3. Snapshot leads to invalid diffs being generated.
536 * 4. Lazy loading grabs entities from old owner object.
537 * 5. New collection is connected to old owner and leads to duplicate keys.
538 */
539 public function __clone()
540 {
541 if (is_object($this->collection)) {
542 $this->collection = clone $this->collection;
543 }
544
545 $this->initialize();
546
547 $this->owner = null;
548 $this->snapshot = [];
549
550 $this->changed();
551 }
552
553 /**
554 * Selects all elements from a selectable that match the expression and
555 * return a new collection containing these elements.
556 *
557 * @psalm-return Collection<TKey, T>
558 *
559 * @throws RuntimeException
560 */
561 public function matching(Criteria $criteria): Collection
562 {
563 if ($this->isDirty) {
564 $this->initialize();
565 }
566
567 if ($this->initialized) {
568 return $this->unwrap()->matching($criteria);
569 }
570
571 $association = $this->getMapping();
572 if ($association->isManyToMany()) {
573 $persister = $this->getUnitOfWork()->getCollectionPersister($association);
574
575 return new ArrayCollection($persister->loadCriteria($this, $criteria));
576 }
577
578 $builder = Criteria::expr();
579 $ownerExpression = $builder->eq($this->backRefFieldName, $this->owner);
580 $expression = $criteria->getWhereExpression();
581 $expression = $expression ? $builder->andX($expression, $ownerExpression) : $ownerExpression;
582
583 $criteria = clone $criteria;
584 $criteria->where($expression);
585 $criteria->orderBy(
586 $criteria->orderings() ?: array_map(
587 static fn (string $order): Order => Order::from(strtoupper($order)),
588 $association->orderBy(),
589 ),
590 );
591
592 $persister = $this->getUnitOfWork()->getEntityPersister($association->targetEntity);
593
594 return $association->fetch === ClassMetadata::FETCH_EXTRA_LAZY
595 ? new LazyCriteriaCollection($persister, $criteria)
596 : new ArrayCollection($persister->loadCriteria($criteria));
597 }
598
599 /**
600 * Retrieves the wrapped Collection instance.
601 *
602 * @return Collection<TKey, T>&Selectable<TKey, T>
603 */
604 public function unwrap(): Selectable&Collection
605 {
606 assert($this->collection instanceof Collection);
607 assert($this->collection instanceof Selectable);
608
609 return $this->collection;
610 }
611
612 protected function doInitialize(): void
613 {
614 // Has NEW objects added through add(). Remember them.
615 $newlyAddedDirtyObjects = [];
616
617 if ($this->isDirty) {
618 $newlyAddedDirtyObjects = $this->unwrap()->toArray();
619 }
620
621 $this->unwrap()->clear();
622 $this->getUnitOfWork()->loadCollection($this);
623 $this->takeSnapshot();
624
625 if ($newlyAddedDirtyObjects) {
626 $this->restoreNewObjectsInDirtyCollection($newlyAddedDirtyObjects);
627 }
628 }
629
630 /**
631 * @param object[] $newObjects
632 *
633 * Note: the only reason why this entire looping/complexity is performed via `spl_object_id`
634 * is because we want to prevent using `array_udiff()`, which is likely to cause very
635 * high overhead (complexity of O(n^2)). `array_diff_key()` performs the operation in
636 * core, which is faster than using a callback for comparisons
637 */
638 private function restoreNewObjectsInDirtyCollection(array $newObjects): void
639 {
640 $loadedObjects = $this->unwrap()->toArray();
641 $newObjectsByOid = array_combine(array_map('spl_object_id', $newObjects), $newObjects);
642 $loadedObjectsByOid = array_combine(array_map('spl_object_id', $loadedObjects), $loadedObjects);
643 $newObjectsThatWereNotLoaded = array_diff_key($newObjectsByOid, $loadedObjectsByOid);
644
645 if ($newObjectsThatWereNotLoaded) {
646 // Reattach NEW objects added through add(), if any.
647 array_walk($newObjectsThatWereNotLoaded, [$this->unwrap(), 'add']);
648
649 $this->isDirty = true;
650 }
651 }
652}