* @template-implements Selectable */ final class PersistentCollection extends AbstractLazyCollection implements Selectable { /** * A snapshot of the collection at the moment it was fetched from the database. * This is used to create a diff of the collection at commit time. * * @psalm-var array */ private array $snapshot = []; /** * The entity that owns this collection. */ private object|null $owner = null; /** * The association mapping the collection belongs to. * This is currently either a OneToManyMapping or a ManyToManyMapping. * * @var (AssociationMapping&ToManyAssociationMapping)|null */ private AssociationMapping|null $association = null; /** * The name of the field on the target entities that points to the owner * of the collection. This is only set if the association is bi-directional. */ private string|null $backRefFieldName = null; /** * Whether the collection is dirty and needs to be synchronized with the database * when the UnitOfWork that manages its persistent state commits. */ private bool $isDirty = false; /** * Creates a new persistent collection. * * @param EntityManagerInterface $em The EntityManager the collection will be associated with. * @param ClassMetadata $typeClass The class descriptor of the entity type of this collection. * @psalm-param Collection&Selectable $collection The collection elements. */ public function __construct( private EntityManagerInterface|null $em, private readonly ClassMetadata|null $typeClass, Collection $collection, ) { $this->collection = $collection; $this->initialized = true; } /** * INTERNAL: * Sets the collection's owning entity together with the AssociationMapping that * describes the association between the owner and the elements of the collection. */ public function setOwner(object $entity, AssociationMapping&ToManyAssociationMapping $assoc): void { $this->owner = $entity; $this->association = $assoc; $this->backRefFieldName = $assoc->isOwningSide() ? $assoc->inversedBy : $assoc->mappedBy; } /** * INTERNAL: * Gets the collection owner. */ public function getOwner(): object|null { return $this->owner; } public function getTypeClass(): ClassMetadata { assert($this->typeClass !== null); return $this->typeClass; } private function getUnitOfWork(): UnitOfWork { assert($this->em !== null); return $this->em->getUnitOfWork(); } /** * INTERNAL: * Adds an element to a collection during hydration. This will automatically * complete bidirectional associations in the case of a one-to-many association. */ public function hydrateAdd(mixed $element): void { $this->unwrap()->add($element); // If _backRefFieldName is set and its a one-to-many association, // we need to set the back reference. if ($this->backRefFieldName && $this->getMapping()->isOneToMany()) { assert($this->typeClass !== null); // Set back reference to owner $this->typeClass->reflFields[$this->backRefFieldName]->setValue( $element, $this->owner, ); $this->getUnitOfWork()->setOriginalEntityProperty( spl_object_id($element), $this->backRefFieldName, $this->owner, ); } } /** * INTERNAL: * Sets a keyed element in the collection during hydration. */ public function hydrateSet(mixed $key, mixed $element): void { $this->unwrap()->set($key, $element); // If _backRefFieldName is set, then the association is bidirectional // and we need to set the back reference. if ($this->backRefFieldName && $this->getMapping()->isOneToMany()) { assert($this->typeClass !== null); // Set back reference to owner $this->typeClass->reflFields[$this->backRefFieldName]->setValue( $element, $this->owner, ); } } /** * Initializes the collection by loading its contents from the database * if the collection is not yet initialized. */ public function initialize(): void { if ($this->initialized || ! $this->association) { return; } $this->doInitialize(); $this->initialized = true; } /** * INTERNAL: * Tells this collection to take a snapshot of its current state. */ public function takeSnapshot(): void { $this->snapshot = $this->unwrap()->toArray(); $this->isDirty = false; } /** * INTERNAL: * Returns the last snapshot of the elements in the collection. * * @psalm-return array The last snapshot of the elements. */ public function getSnapshot(): array { return $this->snapshot; } /** * INTERNAL: * getDeleteDiff * * @return mixed[] */ public function getDeleteDiff(): array { $collectionItems = $this->unwrap()->toArray(); return array_values(array_diff_key( array_combine(array_map('spl_object_id', $this->snapshot), $this->snapshot), array_combine(array_map('spl_object_id', $collectionItems), $collectionItems), )); } /** * INTERNAL: * getInsertDiff * * @return mixed[] */ public function getInsertDiff(): array { $collectionItems = $this->unwrap()->toArray(); return array_values(array_diff_key( array_combine(array_map('spl_object_id', $collectionItems), $collectionItems), array_combine(array_map('spl_object_id', $this->snapshot), $this->snapshot), )); } /** INTERNAL: Gets the association mapping of the collection. */ public function getMapping(): AssociationMapping&ToManyAssociationMapping { if ($this->association === null) { throw new UnexpectedValueException('The underlying association mapping is null although it should not be'); } return $this->association; } /** * Marks this collection as changed/dirty. */ private function changed(): void { if ($this->isDirty) { return; } $this->isDirty = true; } /** * Gets a boolean flag indicating whether this collection is dirty which means * its state needs to be synchronized with the database. */ public function isDirty(): bool { return $this->isDirty; } /** * Sets a boolean flag, indicating whether this collection is dirty. */ public function setDirty(bool $dirty): void { $this->isDirty = $dirty; } /** * Sets the initialized flag of the collection, forcing it into that state. */ public function setInitialized(bool $bool): void { $this->initialized = $bool; } public function remove(string|int $key): mixed { // TODO: If the keys are persistent as well (not yet implemented) // and the collection is not initialized and orphanRemoval is // not used we can issue a straight SQL delete/update on the // association (table). Without initializing the collection. $removed = parent::remove($key); if (! $removed) { return $removed; } $this->changed(); if ( $this->association !== null && $this->association->isToMany() && $this->owner && $this->getMapping()->orphanRemoval ) { $this->getUnitOfWork()->scheduleOrphanRemoval($removed); } return $removed; } public function removeElement(mixed $element): bool { $removed = parent::removeElement($element); if (! $removed) { return $removed; } $this->changed(); if ( $this->association !== null && $this->association->isToMany() && $this->owner && $this->getMapping()->orphanRemoval ) { $this->getUnitOfWork()->scheduleOrphanRemoval($element); } return $removed; } public function containsKey(mixed $key): bool { if ( ! $this->initialized && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY && isset($this->getMapping()->indexBy) ) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping()); return $this->unwrap()->containsKey($key) || $persister->containsKey($this, $key); } return parent::containsKey($key); } public function contains(mixed $element): bool { if (! $this->initialized && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping()); return $this->unwrap()->contains($element) || $persister->contains($this, $element); } return parent::contains($element); } public function get(string|int $key): mixed { if ( ! $this->initialized && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY && isset($this->getMapping()->indexBy) ) { assert($this->em !== null); assert($this->typeClass !== null); if (! $this->typeClass->isIdentifierComposite && $this->typeClass->isIdentifier($this->getMapping()->indexBy)) { return $this->em->find($this->typeClass->name, $key); } return $this->getUnitOfWork()->getCollectionPersister($this->getMapping())->get($this, $key); } return parent::get($key); } public function count(): int { if (! $this->initialized && $this->association !== null && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->association); return $persister->count($this) + ($this->isDirty ? $this->unwrap()->count() : 0); } return parent::count(); } public function set(string|int $key, mixed $value): void { parent::set($key, $value); $this->changed(); if (is_object($value) && $this->em) { $this->getUnitOfWork()->cancelOrphanRemoval($value); } } public function add(mixed $value): bool { $this->unwrap()->add($value); $this->changed(); if (is_object($value) && $this->em) { $this->getUnitOfWork()->cancelOrphanRemoval($value); } return true; } public function offsetExists(mixed $offset): bool { return $this->containsKey($offset); } public function offsetGet(mixed $offset): mixed { return $this->get($offset); } public function offsetSet(mixed $offset, mixed $value): void { if (! isset($offset)) { $this->add($value); return; } $this->set($offset, $value); } public function offsetUnset(mixed $offset): void { $this->remove($offset); } public function isEmpty(): bool { return $this->unwrap()->isEmpty() && $this->count() === 0; } public function clear(): void { if ($this->initialized && $this->isEmpty()) { $this->unwrap()->clear(); return; } $uow = $this->getUnitOfWork(); $association = $this->getMapping(); if ( $association->isToMany() && $association->orphanRemoval && $this->owner ) { // we need to initialize here, as orphan removal acts like implicit cascadeRemove, // hence for event listeners we need the objects in memory. $this->initialize(); foreach ($this->unwrap() as $element) { $uow->scheduleOrphanRemoval($element); } } $this->unwrap()->clear(); $this->initialized = true; // direct call, {@link initialize()} is too expensive if ($association->isOwningSide() && $this->owner) { $this->changed(); $uow->scheduleCollectionDeletion($this); $this->takeSnapshot(); } } /** * Called by PHP when this collection is serialized. Ensures that only the * elements are properly serialized. * * Internal note: Tried to implement Serializable first but that did not work well * with circular references. This solution seems simpler and works well. * * @return string[] * @psalm-return array{0: string, 1: string} */ public function __sleep(): array { return ['collection', 'initialized']; } public function __wakeup(): void { $this->em = null; } /** * Extracts a slice of $length elements starting at position $offset from the Collection. * * If $length is null it returns all elements from $offset to the end of the Collection. * Keys have to be preserved by this method. Calling this method will only return the * selected slice and NOT change the elements contained in the collection slice is called on. * * @return mixed[] * @psalm-return array */ public function slice(int $offset, int|null $length = null): array { if (! $this->initialized && ! $this->isDirty && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping()); return $persister->slice($this, $offset, $length); } return parent::slice($offset, $length); } /** * Cleans up internal state of cloned persistent collection. * * The following problems have to be prevented: * 1. Added entities are added to old PC * 2. New collection is not dirty, if reused on other entity nothing * changes. * 3. Snapshot leads to invalid diffs being generated. * 4. Lazy loading grabs entities from old owner object. * 5. New collection is connected to old owner and leads to duplicate keys. */ public function __clone() { if (is_object($this->collection)) { $this->collection = clone $this->collection; } $this->initialize(); $this->owner = null; $this->snapshot = []; $this->changed(); } /** * Selects all elements from a selectable that match the expression and * return a new collection containing these elements. * * @psalm-return Collection * * @throws RuntimeException */ public function matching(Criteria $criteria): Collection { if ($this->isDirty) { $this->initialize(); } if ($this->initialized) { return $this->unwrap()->matching($criteria); } $association = $this->getMapping(); if ($association->isManyToMany()) { $persister = $this->getUnitOfWork()->getCollectionPersister($association); return new ArrayCollection($persister->loadCriteria($this, $criteria)); } $builder = Criteria::expr(); $ownerExpression = $builder->eq($this->backRefFieldName, $this->owner); $expression = $criteria->getWhereExpression(); $expression = $expression ? $builder->andX($expression, $ownerExpression) : $ownerExpression; $criteria = clone $criteria; $criteria->where($expression); $criteria->orderBy( $criteria->orderings() ?: array_map( static fn (string $order): Order => Order::from(strtoupper($order)), $association->orderBy(), ), ); $persister = $this->getUnitOfWork()->getEntityPersister($association->targetEntity); return $association->fetch === ClassMetadata::FETCH_EXTRA_LAZY ? new LazyCriteriaCollection($persister, $criteria) : new ArrayCollection($persister->loadCriteria($criteria)); } /** * Retrieves the wrapped Collection instance. * * @return Collection&Selectable */ public function unwrap(): Selectable&Collection { assert($this->collection instanceof Collection); assert($this->collection instanceof Selectable); return $this->collection; } protected function doInitialize(): void { // Has NEW objects added through add(). Remember them. $newlyAddedDirtyObjects = []; if ($this->isDirty) { $newlyAddedDirtyObjects = $this->unwrap()->toArray(); } $this->unwrap()->clear(); $this->getUnitOfWork()->loadCollection($this); $this->takeSnapshot(); if ($newlyAddedDirtyObjects) { $this->restoreNewObjectsInDirtyCollection($newlyAddedDirtyObjects); } } /** * @param object[] $newObjects * * Note: the only reason why this entire looping/complexity is performed via `spl_object_id` * is because we want to prevent using `array_udiff()`, which is likely to cause very * high overhead (complexity of O(n^2)). `array_diff_key()` performs the operation in * core, which is faster than using a callback for comparisons */ private function restoreNewObjectsInDirtyCollection(array $newObjects): void { $loadedObjects = $this->unwrap()->toArray(); $newObjectsByOid = array_combine(array_map('spl_object_id', $newObjects), $newObjects); $loadedObjectsByOid = array_combine(array_map('spl_object_id', $loadedObjects), $loadedObjects); $newObjectsThatWereNotLoaded = array_diff_key($newObjectsByOid, $loadedObjectsByOid); if ($newObjectsThatWereNotLoaded) { // Reattach NEW objects added through add(), if any. array_walk($newObjectsThatWereNotLoaded, [$this->unwrap(), 'add']); $this->isDirty = true; } } }