<?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\Order;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ToManyAssociationMapping;
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 is_object;
use function spl_object_id;
use function strtoupper;
/**
* 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>
*/
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 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<TKey, T>&Selectable<TKey, T> $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<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. */
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<TKey,T>
*/
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<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->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<TKey, T>&Selectable<TKey, T>
*/
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;
}
}
}