* @template-implements Selectable * @psalm-import-type AssociationMapping from ClassMetadata */ 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 $snapshot = []; /** * The entity that owns this collection. * * @var object|null */ private $owner; /** * The association mapping the collection belongs to. * This is currently either a OneToManyMapping or a ManyToManyMapping. * * @psalm-var AssociationMapping|null */ private $association; /** * The EntityManager that manages the persistence of the collection. * * @var EntityManagerInterface|null */ private $em; /** * 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. * * @var string|null */ private $backRefFieldName; /** * The class descriptor of the collection's entity type. * * @var ClassMetadata|null */ private $typeClass; /** * Whether the collection is dirty and needs to be synchronized with the database * when the UnitOfWork that manages its persistent state commits. * * @var bool */ private $isDirty = false; /** * Creates a new persistent collection. * * @param EntityManagerInterface $em The EntityManager the collection will be associated with. * @param ClassMetadata $class The class descriptor of the entity type of this collection. * @psalm-param Collection&Selectable $collection The collection elements. */ public function __construct(EntityManagerInterface $em, $class, Collection $collection) { $this->collection = $collection; $this->em = $em; $this->typeClass = $class; $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. * * @param object $entity * @psalm-param AssociationMapping $assoc */ public function setOwner($entity, array $assoc): void { $this->owner = $entity; $this->association = $assoc; $this->backRefFieldName = $assoc['inversedBy'] ?: $assoc['mappedBy']; } /** * INTERNAL: * Gets the collection owner. * * @return object|null */ public function getOwner() { return $this->owner; } /** @return Mapping\ClassMetadata */ public function getTypeClass(): Mapping\ClassMetadataInfo { 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. * * @param mixed $element The element to add. */ public function hydrateAdd($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()['type'] === ClassMetadata::ONE_TO_MANY) { 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. * * @param mixed $key The key to set. * @param mixed $element The element to set. */ public function hydrateSet($key, $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()['type'] === ClassMetadata::ONE_TO_MANY) { 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. * * @psalm-return AssociationMapping */ public function getMapping(): array { 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; if ( $this->association !== null && $this->getMapping()['isOwningSide'] && $this->getMapping()['type'] === ClassMetadata::MANY_TO_MANY && $this->owner && $this->em !== null && $this->em->getClassMetadata(get_class($this->owner))->isChangeTrackingNotify() ) { $this->getUnitOfWork()->scheduleForDirtyCheck($this->owner); } } /** * Gets a boolean flag indicating whether this collection is dirty which means * its state needs to be synchronized with the database. * * @return bool TRUE if the collection is dirty, FALSE otherwise. */ public function isDirty(): bool { return $this->isDirty; } /** * Sets a boolean flag, indicating whether this collection is dirty. * * @param bool $dirty Whether the collection should be marked dirty or not. */ public function setDirty($dirty): void { $this->isDirty = $dirty; } /** * Sets the initialized flag of the collection, forcing it into that state. * * @param bool $bool */ public function setInitialized($bool): void { $this->initialized = $bool; } /** * {@inheritDoc} */ public function remove($key) { // 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->getMapping()['type'] & ClassMetadata::TO_MANY && $this->owner && $this->getMapping()['orphanRemoval'] ) { $this->getUnitOfWork()->scheduleOrphanRemoval($removed); } return $removed; } /** * {@inheritDoc} */ public function removeElement($element): bool { $removed = parent::removeElement($element); if (! $removed) { return $removed; } $this->changed(); if ( $this->association !== null && $this->getMapping()['type'] & ClassMetadata::TO_MANY && $this->owner && $this->getMapping()['orphanRemoval'] ) { $this->getUnitOfWork()->scheduleOrphanRemoval($element); } return $removed; } /** * {@inheritDoc} */ public function containsKey($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); } /** * {@inheritDoc} * * @template TMaybeContained */ public function contains($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); } /** * {@inheritDoc} */ public function get($key) { 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(); } /** * {@inheritDoc} */ public function set($key, $value): void { parent::set($key, $value); $this->changed(); if (is_object($value) && $this->em) { $this->getUnitOfWork()->cancelOrphanRemoval($value); } } /** * {@inheritDoc} */ public function add($value): bool { $this->unwrap()->add($value); $this->changed(); if (is_object($value) && $this->em) { $this->getUnitOfWork()->cancelOrphanRemoval($value); } return true; } /* ArrayAccess implementation */ /** * {@inheritDoc} */ public function offsetExists($offset): bool { return $this->containsKey($offset); } /** * {@inheritDoc} */ #[ReturnTypeWillChange] public function offsetGet($offset) { return $this->get($offset); } /** * {@inheritDoc} */ public function offsetSet($offset, $value): void { if (! isset($offset)) { $this->add($value); return; } $this->set($offset, $value); } /** * {@inheritDoc} * * @return object|null */ #[ReturnTypeWillChange] public function offsetUnset($offset) { return $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['type'] & ClassMetadata::TO_MANY && $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']; } /** * 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. * * @param int $offset * @param int|null $length * * @return mixed[] * @psalm-return array */ public function slice($offset, $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['type'] === ClassMetadata::MANY_TO_MANY) { $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->getOrderings() ?: $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(): 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; } } }