vendor/doctrine/orm/src/PersistentCollection.php line 43

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