diff --git a/composer.json b/composer.json index 475b0f291ae..f7344a0f21a 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ }, "require-dev": { "doctrine/annotations": "^1.13", - "doctrine/coding-standard": "^9.0", + "doctrine/coding-standard": "^9.0.2", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "1.8.2", "phpunit/phpunit": "^9.5", diff --git a/docs/en/reference/query-builder.rst b/docs/en/reference/query-builder.rst index 60cd5f8aff0..50a1b8dc73c 100644 --- a/docs/en/reference/query-builder.rst +++ b/docs/en/reference/query-builder.rst @@ -268,7 +268,7 @@ Doctrine\DBAL\ParameterType::* or a DBAL Type name for conversion. use Doctrine\DBAL\Types\Types; // prevents attempt to load metadata for date time class, improving performance - $qb->setParameter('date', new \DateTimeImmutable(), Types::DATE_IMMUTABLE) + $qb->setParameter('date', new \DateTimeImmutable(), Types::DATETIME_IMMUTABLE) If you've got several parameters to bind to your query, you can also use setParameters() instead of setParameter() with the diff --git a/docs/en/reference/working-with-associations.rst b/docs/en/reference/working-with-associations.rst index ea7893d8e1a..510d85f05b3 100644 --- a/docs/en/reference/working-with-associations.rst +++ b/docs/en/reference/working-with-associations.rst @@ -37,7 +37,7 @@ information about its type and if it's the owning or inverse side. { /** @Id @GeneratedValue @Column(type="string") */ private $id; - + /** * Bidirectional - Many users have Many favorite comments (OWNING SIDE) * @@ -45,7 +45,7 @@ information about its type and if it's the owning or inverse side. * @JoinTable(name="user_favorite_comments") */ private $favorites; - + /** * Unidirectional - Many users have marked many comments as read * @@ -53,14 +53,14 @@ information about its type and if it's the owning or inverse side. * @JoinTable(name="user_read_comments") */ private $commentsRead; - + /** * Bidirectional - One-To-Many (INVERSE SIDE) * * @OneToMany(targetEntity="Comment", mappedBy="author") */ private $commentsAuthored; - + /** * Unidirectional - Many-To-One * @@ -68,20 +68,20 @@ information about its type and if it's the owning or inverse side. */ private $firstComment; } - + /** @Entity */ class Comment { /** @Id @GeneratedValue @Column(type="string") */ private $id; - + /** * Bidirectional - Many comments are favorited by many users (INVERSE SIDE) * * @ManyToMany(targetEntity="User", mappedBy="favorites") */ private $userFavorites; - + /** * Bidirectional - Many Comments are authored by one user (OWNING SIDE) * @@ -100,19 +100,19 @@ definitions omitted): firstComment_id VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id) ) ENGINE = InnoDB; - + CREATE TABLE Comment ( id VARCHAR(255) NOT NULL, author_id VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id) ) ENGINE = InnoDB; - + CREATE TABLE user_favorite_comments ( user_id VARCHAR(255) NOT NULL, favorite_comment_id VARCHAR(255) NOT NULL, PRIMARY KEY(user_id, favorite_comment_id) ) ENGINE = InnoDB; - + CREATE TABLE user_read_comments ( user_id VARCHAR(255) NOT NULL, comment_id VARCHAR(255) NOT NULL, @@ -135,7 +135,7 @@ relations of the ``User``: public function getReadComments() { return $this->commentsRead; } - + public function setFirstComment(Comment $c) { $this->firstComment = $c; } @@ -148,17 +148,17 @@ The interaction code would then look like in the following snippet find('User', $userId); - + // unidirectional many to many $comment = $em->find('Comment', $readCommentId); $user->getReadComments()->add($comment); - + $em->flush(); - + // unidirectional many to one $myFirstComment = new Comment(); $user->setFirstComment($myFirstComment); - + $em->persist($myFirstComment); $em->flush(); @@ -171,40 +171,40 @@ fields on both sides: class User { // .. - + public function getAuthoredComments() { return $this->commentsAuthored; } - + public function getFavoriteComments() { return $this->favorites; } } - + class Comment { // ... - + public function getUserFavorites() { return $this->userFavorites; } - + public function setAuthor(User $author = null) { $this->author = $author; } } - + // Many-to-Many $user->getFavorites()->add($favoriteComment); $favoriteComment->getUserFavorites()->add($user); - + $em->flush(); - + // Many-To-One / One-To-Many Bidirectional $newComment = new Comment(); $user->getAuthoredComments()->add($newComment); $newComment->setAuthor($user); - + $em->persist($newComment); $em->flush(); @@ -225,10 +225,10 @@ element. Here are some examples: // Remove by Elements $user->getComments()->removeElement($comment); $comment->setAuthor(null); - + $user->getFavorites()->removeElement($comment); $comment->getUserFavorites()->removeElement($user); - + // Remove by Key $user->getComments()->remove($ithComment); $comment->setAuthor(null); @@ -240,7 +240,7 @@ Notice how both sides of the bidirectional association are always updated. Unidirectional associations are consequently simpler to handle. -Also note that if you use type-hinting in your methods, you will +Also note that if you use type-hinting in your methods, you will have to specify a nullable type, i.e. ``setAddress(?Address $address)``, otherwise ``setAddress(null)`` will fail to remove the association. Another way to deal with this is to provide a special method, like @@ -271,8 +271,8 @@ entities that have been re-added to the collection. Say you clear a collection of tags by calling ``$post->getTags()->clear();`` and then call -``$post->getTags()->add($tag)``. This will not recognize the tag having -already been added previously and will consequently issue two separate database +``$post->getTags()->add($tag)``. This will not recognize the tag having +already been added previously and will consequently issue two separate database calls. Association Management Methods @@ -296,7 +296,7 @@ example that encapsulate much of the association management code: // Collections implement ArrayAccess $this->commentsRead[] = $comment; } - + public function addComment(Comment $comment) { if (count($this->commentsAuthored) == 0) { $this->setFirstComment($comment); @@ -304,30 +304,30 @@ example that encapsulate much of the association management code: $this->comments[] = $comment; $comment->setAuthor($this); } - + private function setFirstComment(Comment $c) { $this->firstComment = $c; } - + public function addFavorite(Comment $comment) { $this->favorites->add($comment); $comment->addUserFavorite($this); } - + public function removeFavorite(Comment $comment) { $this->favorites->removeElement($comment); $comment->removeUserFavorite($this); } } - + class Comment { // .. - + public function addUserFavorite(User $user) { $this->userFavorites[] = $user; } - + public function removeUserFavorite(User $user) { $this->userFavorites->removeElement($user); } @@ -373,7 +373,7 @@ as your preferences. Synchronizing Bidirectional Collections --------------------------------------- -In the case of Many-To-Many associations you as the developer have the +In the case of Many-To-Many associations you as the developer have the responsibility of keeping the collections on the owning and inverse side in sync when you apply changes to them. Doctrine can only guarantee a consistent state for the hydration, not for your client @@ -387,7 +387,7 @@ can show the possible caveats you can encounter: getFavorites()->add($favoriteComment); // not calling $favoriteComment->getUserFavorites()->add($user); - + $user->getFavorites()->contains($favoriteComment); // TRUE $favoriteComment->getUserFavorites()->contains($user); // FALSE @@ -422,7 +422,7 @@ comment might look like in your controller (without ``cascade: persist``): $user = new User(); $myFirstComment = new Comment(); $user->addComment($myFirstComment); - + $em->persist($user); $em->persist($myFirstComment); // required, if `cascade: persist` is not set $em->flush(); @@ -480,7 +480,7 @@ If you then set up the cascading to the ``User#commentsAuthored`` property... comment('Lorem ipsum', new DateTime()); - + $em->persist($user); $em->flush(); @@ -559,6 +559,13 @@ OrphanRemoval works with one-to-one, one-to-many and many-to-many associations. If you neglect this assumption your entities will get deleted by Doctrine even if you assigned the orphaned entity to another one. +.. note:: + + ``orphanRemoval=true`` option should be used in combination with ``cascade=["persist"]`` option + as the child entity, that is manually persisted, will not be deleted automatically by Doctrine + when a collection is still an instance of ArrayCollection (before first flush / hydration). + This is a Doctrine limitation since ArrayCollection does not have access to a UnitOfWork. + As a better example consider an Addressbook application where you have Contacts, Addresses and StandingData: @@ -578,10 +585,10 @@ and StandingData: /** @Id @Column(type="integer") @GeneratedValue */ private $id; - /** @OneToOne(targetEntity="StandingData", orphanRemoval=true) */ + /** @OneToOne(targetEntity="StandingData", cascade={"persist"}, orphanRemoval=true) */ private $standingData; - /** @OneToMany(targetEntity="Address", mappedBy="contact", orphanRemoval=true) */ + /** @OneToMany(targetEntity="Address", mappedBy="contact", cascade={"persist"}, orphanRemoval=true) */ private $addresses; public function __construct() @@ -612,10 +619,10 @@ Now two examples of what happens when you remove the references: $em->flush(); -In this case you have not only changed the ``Contact`` entity itself but -you have also removed the references for standing data and as well as one -address reference. When flush is called not only are the references removed -but both the old standing data and the one address entity are also deleted +In this case you have not only changed the ``Contact`` entity itself but +you have also removed the references for standing data and as well as one +address reference. When flush is called not only are the references removed +but both the old standing data and the one address entity are also deleted from the database. .. _filtering-collections: diff --git a/lib/Doctrine/ORM/PersistentCollection.php b/lib/Doctrine/ORM/PersistentCollection.php index cbc92c8985a..86c1036f300 100644 --- a/lib/Doctrine/ORM/PersistentCollection.php +++ b/lib/Doctrine/ORM/PersistentCollection.php @@ -17,6 +17,7 @@ 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; @@ -32,7 +33,8 @@ * * @psalm-template TKey of array-key * @psalm-template T - * @template-implements Collection + * @template-extends AbstractLazyCollection + * @template-implements Selectable */ final class PersistentCollection extends AbstractLazyCollection implements Selectable { @@ -70,7 +72,7 @@ final class PersistentCollection extends AbstractLazyCollection implements Selec * 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 + * @var string|null */ private $backRefFieldName; @@ -144,7 +146,7 @@ public function getTypeClass(): Mapping\ClassMetadata */ public function hydrateAdd($element): void { - $this->collection->add($element); + $this->unwrap()->add($element); // If _backRefFieldName is set and its a one-to-many association, // we need to set the back reference. @@ -172,7 +174,7 @@ public function hydrateAdd($element): void */ public function hydrateSet($key, $element): void { - $this->collection->set($key, $element); + $this->unwrap()->set($key, $element); // If _backRefFieldName is set, then the association is bidirectional // and we need to set the back reference. @@ -206,7 +208,7 @@ public function initialize(): void */ public function takeSnapshot(): void { - $this->snapshot = $this->collection->toArray(); + $this->snapshot = $this->unwrap()->toArray(); $this->isDirty = false; } @@ -229,7 +231,7 @@ public function getSnapshot(): array */ public function getDeleteDiff(): array { - $collectionItems = $this->collection->toArray(); + $collectionItems = $this->unwrap()->toArray(); return array_values(array_diff_key( array_combine(array_map('spl_object_id', $this->snapshot), $this->snapshot), @@ -245,7 +247,7 @@ public function getDeleteDiff(): array */ public function getInsertDiff(): array { - $collectionItems = $this->collection->toArray(); + $collectionItems = $this->unwrap()->toArray(); return array_values(array_diff_key( array_combine(array_map('spl_object_id', $collectionItems), $collectionItems), @@ -318,8 +320,6 @@ public function setInitialized($bool): void /** * {@inheritdoc} - * - * @return object */ public function remove($key) { @@ -383,7 +383,7 @@ public function containsKey($key): bool ) { $persister = $this->em->getUnitOfWork()->getCollectionPersister($this->association); - return $this->collection->containsKey($key) || $persister->containsKey($this, $key); + return $this->unwrap()->containsKey($key) || $persister->containsKey($this, $key); } return parent::containsKey($key); @@ -397,7 +397,7 @@ public function contains($element): bool if (! $this->initialized && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->em->getUnitOfWork()->getCollectionPersister($this->association); - return $this->collection->contains($element) || $persister->contains($this, $element); + return $this->unwrap()->contains($element) || $persister->contains($this, $element); } return parent::contains($element); @@ -428,7 +428,7 @@ public function count(): int if (! $this->initialized && $this->association !== null && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->em->getUnitOfWork()->getCollectionPersister($this->association); - return $persister->count($this) + ($this->isDirty ? $this->collection->count() : 0); + return $persister->count($this) + ($this->isDirty ? $this->unwrap()->count() : 0); } return parent::count(); @@ -453,7 +453,7 @@ public function set($key, $value): void */ public function add($value): bool { - $this->collection->add($value); + $this->unwrap()->add($value); $this->changed(); @@ -506,13 +506,13 @@ public function offsetUnset($offset): void public function isEmpty(): bool { - return $this->collection->isEmpty() && $this->count() === 0; + return $this->unwrap()->isEmpty() && $this->count() === 0; } public function clear(): void { if ($this->initialized && $this->isEmpty()) { - $this->collection->clear(); + $this->unwrap()->clear(); return; } @@ -528,12 +528,12 @@ public function clear(): void // hence for event listeners we need the objects in memory. $this->initialize(); - foreach ($this->collection as $element) { + foreach ($this->unwrap() as $element) { $uow->scheduleOrphanRemoval($element); } } - $this->collection->clear(); + $this->unwrap()->clear(); $this->initialized = true; // direct call, {@link initialize()} is too expensive @@ -625,7 +625,7 @@ public function matching(Criteria $criteria): Collection } if ($this->initialized) { - return $this->collection->matching($criteria); + return $this->unwrap()->matching($criteria); } if ($this->association['type'] === ClassMetadata::MANY_TO_MANY) { @@ -657,6 +657,8 @@ public function matching(Criteria $criteria): Collection */ public function unwrap(): Collection { + assert($this->collection !== null); + return $this->collection; } @@ -666,10 +668,10 @@ protected function doInitialize(): void $newlyAddedDirtyObjects = []; if ($this->isDirty) { - $newlyAddedDirtyObjects = $this->collection->toArray(); + $newlyAddedDirtyObjects = $this->unwrap()->toArray(); } - $this->collection->clear(); + $this->unwrap()->clear(); $this->em->getUnitOfWork()->loadCollection($this); $this->takeSnapshot(); @@ -688,14 +690,14 @@ protected function doInitialize(): void */ private function restoreNewObjectsInDirtyCollection(array $newObjects): void { - $loadedObjects = $this->collection->toArray(); + $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->collection, 'add']); + array_walk($newObjectsThatWereNotLoaded, [$this->unwrap(), 'add']); $this->isDirty = true; } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e8cc0895d7b..0fbfb3d81a1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -196,12 +196,7 @@ parameters: path: lib/Doctrine/ORM/NativeQuery.php - - message: "#^Call to an undefined method Doctrine\\\\Common\\\\Collections\\\\Collection\\<\\(int\\|string\\), mixed\\>\\:\\:matching\\(\\)\\.$#" - count: 1 - path: lib/Doctrine/ORM/PersistentCollection.php - - - - message: "#^Method Doctrine\\\\ORM\\\\PersistentCollection\\:\\:remove\\(\\) should return object but returns array\\|float\\|int\\|string\\|false\\|null\\.$#" + message: "#^Call to an undefined method Doctrine\\\\Common\\\\Collections\\\\Collection\\\\:\\:matching\\(\\)\\.$#" count: 1 path: lib/Doctrine/ORM/PersistentCollection.php @@ -210,11 +205,6 @@ parameters: count: 2 path: lib/Doctrine/ORM/PersistentCollection.php - - - message: "#^The @implements tag of class Doctrine\\\\ORM\\\\PersistentCollection describes Doctrine\\\\Common\\\\Collections\\\\Collection but the class implements\\: Doctrine\\\\Common\\\\Collections\\\\Selectable$#" - count: 1 - path: lib/Doctrine/ORM/PersistentCollection.php - - message: "#^Parameter \\#3 \\$hints of method Doctrine\\\\ORM\\\\Internal\\\\Hydration\\\\AbstractHydrator\\:\\:hydrateAll\\(\\) expects array\\, array\\ given\\.$#" count: 1 diff --git a/phpstan.neon b/phpstan.neon index c9ced268155..3a599ca6fc9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,11 +4,6 @@ includes: parameters: ignoreErrors: - # False positive - - - message: '/^Variable \$offset in isset\(\) always exists and is not nullable\.$/' - path: lib/Doctrine/ORM/PersistentCollection.php - # Symfony cache supports passing a key prefix to the clear method. - '/^Method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) invoked with 1 parameter, 0 required\.$/' diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 158d6575ce5..1bf157d6cb3 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -301,11 +301,6 @@ $newObject['args'] - - - LazyCriteriaCollection - - $repositoryClassName @@ -762,24 +757,21 @@ - - isset($offset) - - - final class PersistentCollection extends AbstractLazyCollection implements Selectable - - - - $key - + + Collection<TKey, T> + + + $this->em->find($this->typeClass->name, $key) + $value - + $this->association $this->association $this->association $this->association['targetEntity'] + $this->backRefFieldName $this->association['fetch'] @@ -799,13 +791,9 @@ setValue setValue - - $backRefFieldName - - + $this->em $this->em - is_object($this->collection) is_object($value) && $this->em is_object($value) && $this->em