<?php
declare(strict_types=1);
namespace Doctrine\ORM;
use Doctrine\Common\Collections\AbstractLazyCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping\ClassMetadata;
use ReturnTypeWillChange;
use RuntimeException;
use UnexpectedValueException;
use function array_combine;
use function array_diff_key;
use function array_map;
use function array_values;
use function array_walk;
use function assert;
use function get_class;
use function is_object;
use function spl_object_id;
/**
* A PersistentCollection represents a collection of elements that have persistent state.
*
* Collections of entities represent only the associations (links) to those entities.
* That means, if the collection is part of a many-many mapping and you remove
* entities from the collection, only the links in the relation table are removed (on flush).
* Similarly, if you remove entities from a collection that is part of a one-many
* mapping this will only result in the nulling out of the foreign keys on flush.
*
* @psalm-template TKey of array-key
* @psalm-template T
* @template-extends AbstractLazyCollection<TKey,T>
* @template-implements Selectable<TKey,T>
* @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<string|int, mixed>
*/
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<TKey, T>&Selectable<TKey, T> $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<string|int, mixed> 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<TKey,T>
*/
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<TKey, T>
*
* @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<TKey, T>&Selectable<TKey, T>
*/
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;
}
}
}