diff --git a/lib/Doctrine/ORM/Cache/CacheFactory.php b/lib/Doctrine/ORM/Cache/CacheFactory.php index 8aa7388984c..b15c23e8df4 100644 --- a/lib/Doctrine/ORM/Cache/CacheFactory.php +++ b/lib/Doctrine/ORM/Cache/CacheFactory.php @@ -8,14 +8,13 @@ use Doctrine\ORM\Cache\Persister\Collection\CachedCollectionPersister; use Doctrine\ORM\Cache\Persister\Entity\CachedEntityPersister; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use Doctrine\ORM\Persisters\Entity\EntityPersister; /** * Contract for building second level cache regions components. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ interface CacheFactory { @@ -24,12 +23,12 @@ interface CacheFactory */ public function buildCachedEntityPersister(EntityManagerInterface $em, EntityPersister $persister, ClassMetadata $metadata): CachedEntityPersister; - /** - * Build a collection persister for the given relation mapping. - * - * @param AssociationMapping $mapping The association mapping. - */ - public function buildCachedCollectionPersister(EntityManagerInterface $em, CollectionPersister $persister, array $mapping): CachedCollectionPersister; + /** Build a collection persister for the given relation mapping. */ + public function buildCachedCollectionPersister( + EntityManagerInterface $em, + CollectionPersister $persister, + AssociationMapping $mapping, + ): CachedCollectionPersister; /** * Build a query cache based on the given region name @@ -43,10 +42,8 @@ public function buildEntityHydrator(EntityManagerInterface $em, ClassMetadata $m /** * Build a collection hydrator - * - * @param mixed[] $mapping The association mapping. */ - public function buildCollectionHydrator(EntityManagerInterface $em, array $mapping): CollectionHydrator; + public function buildCollectionHydrator(EntityManagerInterface $em, AssociationMapping $mapping): CollectionHydrator; /** * Build a cache region diff --git a/lib/Doctrine/ORM/Cache/DefaultCache.php b/lib/Doctrine/ORM/Cache/DefaultCache.php index 04264e7eb94..ec7951f7931 100644 --- a/lib/Doctrine/ORM/Cache/DefaultCache.php +++ b/lib/Doctrine/ORM/Cache/DefaultCache.php @@ -157,7 +157,7 @@ public function evictCollectionRegions(): void foreach ($metadatas as $metadata) { foreach ($metadata->associationMappings as $association) { - if (! $association['type'] & ClassMetadata::TO_MANY) { + if (! $association->isToMany()) { continue; } diff --git a/lib/Doctrine/ORM/Cache/DefaultCacheFactory.php b/lib/Doctrine/ORM/Cache/DefaultCacheFactory.php index e770a107ac7..bc7c77188ab 100644 --- a/lib/Doctrine/ORM/Cache/DefaultCacheFactory.php +++ b/lib/Doctrine/ORM/Cache/DefaultCacheFactory.php @@ -17,6 +17,7 @@ use Doctrine\ORM\Cache\Region\FileLockRegion; use Doctrine\ORM\Cache\Region\UpdateTimestampCache; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use Doctrine\ORM\Persisters\Entity\EntityPersister; @@ -87,12 +88,11 @@ public function buildCachedEntityPersister(EntityManagerInterface $em, EntityPer throw new InvalidArgumentException(sprintf('Unrecognized access strategy type [%s]', $usage)); } - /** - * {@inheritdoc} - */ - public function buildCachedCollectionPersister(EntityManagerInterface $em, CollectionPersister $persister, array $mapping): CachedCollectionPersister - { - assert(isset($mapping['cache'])); + public function buildCachedCollectionPersister( + EntityManagerInterface $em, + CollectionPersister $persister, + AssociationMapping $mapping, + ): CachedCollectionPersister { $usage = $mapping['cache']['usage']; $region = $this->getRegion($mapping['cache']); @@ -128,10 +128,7 @@ public function buildQueryCache(EntityManagerInterface $em, string|null $regionN ); } - /** - * {@inheritdoc} - */ - public function buildCollectionHydrator(EntityManagerInterface $em, array $mapping): CollectionHydrator + public function buildCollectionHydrator(EntityManagerInterface $em, AssociationMapping $mapping): CollectionHydrator { return new DefaultCollectionHydrator($em); } diff --git a/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php b/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php index dba3eb219e4..0cbe377d6be 100644 --- a/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php +++ b/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php @@ -57,7 +57,7 @@ public function buildCacheEntry(ClassMetadata $metadata, EntityCacheKey $key, ob continue; } - if (! ($assoc['type'] & ClassMetadata::TO_ONE)) { + if (! $assoc->isToOne()) { unset($data[$name]); continue; @@ -65,7 +65,7 @@ public function buildCacheEntry(ClassMetadata $metadata, EntityCacheKey $key, ob if (! isset($assoc['cache'])) { $targetClassMetadata = $this->em->getClassMetadata($assoc['targetEntity']); - $owningAssociation = ! $assoc['isOwningSide'] + $owningAssociation = ! $assoc->isOwningSide() ? $targetClassMetadata->associationMappings[$assoc['mappedBy']] : $assoc; $associationIds = $this->identifierFlattener->flattenIdentifier( @@ -141,7 +141,7 @@ public function loadCacheEntry(ClassMetadata $metadata, EntityCacheKey $key, Ent $assocClass = $data[$name]->class; $assocId = $data[$name]->identifier; - $isEagerLoad = ($assoc['fetch'] === ClassMetadata::FETCH_EAGER || ($assoc['type'] === ClassMetadata::ONE_TO_ONE && ! $assoc['isOwningSide'])); + $isEagerLoad = ($assoc['fetch'] === ClassMetadata::FETCH_EAGER || ($assoc->isOneToOne() && ! $assoc->isOwningSide())); if (! $isEagerLoad) { $data[$name] = $this->em->getReference($assocClass, $assocId); diff --git a/lib/Doctrine/ORM/Cache/DefaultQueryCache.php b/lib/Doctrine/ORM/Cache/DefaultQueryCache.php index 66f4048215f..1e9f902f3a8 100644 --- a/lib/Doctrine/ORM/Cache/DefaultQueryCache.php +++ b/lib/Doctrine/ORM/Cache/DefaultQueryCache.php @@ -11,6 +11,7 @@ use Doctrine\ORM\Cache\Logging\CacheLogger; use Doctrine\ORM\Cache\Persister\Entity\CachedEntityPersister; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Query; @@ -30,8 +31,6 @@ /** * Default query cache implementation. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ class DefaultQueryCache implements QueryCache { @@ -298,12 +297,10 @@ public function put(QueryCacheKey $key, ResultSetMapping $rsm, mixed $result, ar } /** - * @param AssociationMapping $assoc - * * @return mixed[]|null * @psalm-return array{targetEntity: class-string, type: mixed, list?: array[], identifier?: array}|null */ - private function storeAssociationCache(QueryCacheKey $key, array $assoc, mixed $assocValue): array|null + private function storeAssociationCache(QueryCacheKey $key, AssociationMapping $assoc, mixed $assocValue): array|null { $assocPersister = $this->uow->getEntityPersister($assoc['targetEntity']); $assocMetadata = $assocPersister->getClassMetadata(); diff --git a/lib/Doctrine/ORM/Cache/Persister/Collection/AbstractCollectionPersister.php b/lib/Doctrine/ORM/Cache/Persister/Collection/AbstractCollectionPersister.php index a2072437ee8..c402991d740 100644 --- a/lib/Doctrine/ORM/Cache/Persister/Collection/AbstractCollectionPersister.php +++ b/lib/Doctrine/ORM/Cache/Persister/Collection/AbstractCollectionPersister.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Cache\Persister\Entity\CachedEntityPersister; use Doctrine\ORM\Cache\Region; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataFactory; use Doctrine\ORM\PersistentCollection; @@ -23,7 +24,6 @@ use function assert; use function count; -/** @psalm-import-type AssociationMapping from ClassMetadata */ abstract class AbstractCollectionPersister implements CachedCollectionPersister { protected UnitOfWork $uow; @@ -38,12 +38,11 @@ abstract class AbstractCollectionPersister implements CachedCollectionPersister protected CollectionHydrator $hydrator; protected CacheLogger|null $cacheLogger; - /** @param AssociationMapping $association The association mapping. */ public function __construct( protected CollectionPersister $persister, protected Region $region, EntityManagerInterface $em, - protected array $association, + protected AssociationMapping $association, ) { $configuration = $em->getConfiguration(); $cacheConfig = $configuration->getSecondLevelCacheConfiguration(); diff --git a/lib/Doctrine/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersister.php b/lib/Doctrine/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersister.php index 147d3e67b6b..e28b919d82d 100644 --- a/lib/Doctrine/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersister.php +++ b/lib/Doctrine/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersister.php @@ -7,18 +7,20 @@ use Doctrine\ORM\Cache\CollectionCacheKey; use Doctrine\ORM\Cache\ConcurrentRegion; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use function spl_object_id; -/** @psalm-import-type AssociationMapping from ClassMetadata */ class ReadWriteCachedCollectionPersister extends AbstractCollectionPersister { - /** @param AssociationMapping $association The association mapping. */ - public function __construct(CollectionPersister $persister, ConcurrentRegion $region, EntityManagerInterface $em, array $association) - { + public function __construct( + CollectionPersister $persister, + ConcurrentRegion $region, + EntityManagerInterface $em, + AssociationMapping $association, + ) { parent::__construct($persister, $region, $em, $association); } diff --git a/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php b/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php index 652b8113ba6..53404d9d895 100644 --- a/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php +++ b/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php @@ -18,6 +18,7 @@ use Doctrine\ORM\Cache\TimestampCacheKey; use Doctrine\ORM\Cache\TimestampRegion; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataFactory; use Doctrine\ORM\PersistentCollection; @@ -86,7 +87,7 @@ public function getInserts(): array public function getSelectSQL( array|Criteria $criteria, - array|null $assoc = null, + AssociationMapping|null $assoc = null, LockMode|int|null $lockMode = null, int|null $limit = null, int|null $offset = null, @@ -113,7 +114,7 @@ public function getResultSetMapping(): ResultSetMapping public function getSelectConditionStatementSQL( string $field, mixed $value, - array|null $assoc = null, + AssociationMapping|null $assoc = null, string|null $comparison = null, ): string { return $this->persister->getSelectConditionStatementSQL($field, $value, $assoc, $comparison); @@ -169,8 +170,8 @@ private function storeJoinedAssociations(object $entity): void foreach ($this->class->associationMappings as $name => $assoc) { if ( isset($assoc['cache']) && - ($assoc['type'] & ClassMetadata::TO_ONE) && - ($assoc['fetch'] === ClassMetadata::FETCH_EAGER || ! $assoc['isOwningSide']) + ($assoc->isToOne()) && + ($assoc['fetch'] === ClassMetadata::FETCH_EAGER || ! $assoc->isOwningSide()) ) { $associations[] = $name; } @@ -241,7 +242,7 @@ public function getClassMetadata(): ClassMetadata * {@inheritdoc} */ public function getManyToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, @@ -253,7 +254,7 @@ public function getManyToManyCollection( * {@inheritdoc} */ public function getOneToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, @@ -282,7 +283,7 @@ public function executeInserts(): array public function load( array $criteria, object|null $entity = null, - array|null $assoc = null, + AssociationMapping|null $assoc = null, array $hints = [], LockMode|int|null $lockMode = null, int|null $limit = null, @@ -455,7 +456,7 @@ public function loadCriteria(Criteria $criteria): array * {@inheritdoc} */ public function loadManyToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection, ): array { @@ -485,11 +486,8 @@ public function loadManyToManyCollection( return $list; } - /** - * {@inheritdoc} - */ public function loadOneToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection, ): mixed { @@ -522,7 +520,7 @@ public function loadOneToManyCollection( /** * {@inheritdoc} */ - public function loadOneToOneEntity(array $assoc, object $sourceEntity, array $identifier = []): object|null + public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null { return $this->persister->loadOneToOneEntity($assoc, $sourceEntity, $identifier); } @@ -543,11 +541,8 @@ public function refresh(array $id, object $entity, LockMode|int|null $lockMode = $this->persister->refresh($id, $entity, $lockMode); } - /** - * @param array $association - * @param array $ownerId - */ - protected function buildCollectionCacheKey(array $association, array $ownerId): CollectionCacheKey + /** @param array $ownerId */ + protected function buildCollectionCacheKey(AssociationMapping $association, array $ownerId): CollectionCacheKey { $metadata = $this->metadataFactory->getMetadataFor($association['sourceEntity']); assert($metadata instanceof ClassMetadata); diff --git a/lib/Doctrine/ORM/Internal/Hydration/ArrayHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/ArrayHydrator.php index baf8a26cfa5..57015d4681c 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/ArrayHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/ArrayHydrator.php @@ -4,8 +4,6 @@ namespace Doctrine\ORM\Internal\Hydration; -use Doctrine\ORM\Mapping\ClassMetadata; - use function array_key_last; use function count; use function is_array; @@ -103,7 +101,7 @@ protected function hydrateRowData(array $row, array &$result): void $relation = $parentClass->associationMappings[$relationAlias]; // Check the type of the relation (many or single-valued) - if (! ($relation['type'] & ClassMetadata::TO_ONE)) { + if (! $relation->isToOne()) { $oneToOne = false; if (! isset($baseElement[$relationAlias])) { diff --git a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php index 721ac80fb1e..e6b43fd9996 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php @@ -94,7 +94,7 @@ protected function prepare(): void $class = $this->getClassMetadata($className); $inverseAssoc = $class->associationMappings[$assoc['inversedBy']]; - if (! ($inverseAssoc['type'] & ClassMetadata::TO_ONE)) { + if (! $inverseAssoc->isToOne()) { continue; } @@ -364,7 +364,7 @@ protected function hydrateRowData(array $row, array &$result): void $oid = spl_object_id($parentObject); // Check the type of the relation (many or single-valued) - if (! ($relation['type'] & ClassMetadata::TO_ONE)) { + if (! $relation->isToOne()) { // PATH A: Collection-valued association $reflFieldValue = $reflField->getValue($parentObject); @@ -435,7 +435,7 @@ protected function hydrateRowData(array $row, array &$result): void // If there is an inverse mapping on the target class its bidirectional if ($relation['inversedBy']) { $inverseAssoc = $targetClass->associationMappings[$relation['inversedBy']]; - if ($inverseAssoc['type'] & ClassMetadata::TO_ONE) { + if ($inverseAssoc->isToOne()) { $targetClass->reflFields[$inverseAssoc['fieldName']]->setValue($element, $parentObject); $this->uow->setOriginalEntityProperty(spl_object_id($element), $inverseAssoc['fieldName'], $parentObject); } diff --git a/lib/Doctrine/ORM/Mapping/AnsiQuoteStrategy.php b/lib/Doctrine/ORM/Mapping/AnsiQuoteStrategy.php index a9b953d650a..a911103ccfa 100644 --- a/lib/Doctrine/ORM/Mapping/AnsiQuoteStrategy.php +++ b/lib/Doctrine/ORM/Mapping/AnsiQuoteStrategy.php @@ -36,30 +36,24 @@ public function getSequenceName(array $definition, ClassMetadata $class, Abstrac return $definition['sequenceName']; } - /** - * {@inheritdoc} - */ - public function getJoinColumnName(array $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string + public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string { return $joinColumn['name']; } - /** - * {@inheritdoc} - */ public function getReferencedJoinColumnName( - array $joinColumn, + JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform, ): string { return $joinColumn['referencedColumnName']; } - /** - * {@inheritdoc} - */ - public function getJoinTableName(array $association, ClassMetadata $class, AbstractPlatform $platform): string - { + public function getJoinTableName( + AssociationMapping $association, + ClassMetadata $class, + AbstractPlatform $platform, + ): string { return $association['joinTable']['name']; } diff --git a/lib/Doctrine/ORM/Mapping/AssociationMapping.php b/lib/Doctrine/ORM/Mapping/AssociationMapping.php new file mode 100644 index 00000000000..96dd2647a96 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/AssociationMapping.php @@ -0,0 +1,345 @@ + */ +abstract class AssociationMapping implements ArrayAccess +{ + /** + * required for bidirectional associations + * The name of the field that completes the bidirectional association on + * the owning side. This key must be specified on the inverse side of a + * bidirectional association. + */ + public string|null $mappedBy = null; + + /** + * required for bidirectional associations + * The name of the field that completes the bidirectional association on + * the inverse side. This key must be specified on the owning side of a + * bidirectional association. + */ + public string|null $inversedBy = null; + + /** + * The names of persistence operations to cascade on the association. + * + * @var list<'persist'|'remove'|'detach'|'merge'|'refresh'|'all'> + */ + public array $cascade = []; + + /** + * The fetching strategy to use for the association, usually defaults to FETCH_LAZY. + * + * @var ClassMetadata::FETCH_EAGER|ClassMetadata::FETCH_LAZY + */ + public int|null $fetch = null; + + /** + * This is set when the association is inherited by this class from another + * (inheritance) parent entity class. The value is the FQCN of the + * topmost entity class that contains this association. (If there are + * transient classes in the class hierarchy, these are ignored, so the + * class property may in fact come from a class further up in the PHP class + * hierarchy.) To-many associations initially declared in mapped + * superclasses are not considered 'inherited' in the nearest + * entity subclasses. + * + * @var class-string|null + */ + public string|null $inherited = null; + + /** + * This is set when the association does not appear in the current class + * for the first time, but is initially declared in another parent + * entity or mapped superclass. The value is the FQCN of the + * topmost non-transient class that contains association information for + * this relationship. + * + * @var class-string|null + */ + public string|null $declared = null; + + public array|null $cache = null; + + public bool|null $id = null; + + public bool|null $isOnDeleteCascade = null; + + /** @var array|null */ + public array|null $joinColumnFieldNames = null; + + /** @var list|null */ + public array|null $joinTableColumns = null; + + /** @var class-string|null */ + public string|null $originalClass = null; + + public string|null $originalField = null; + + public bool $orphanRemoval = false; + + public bool|null $unique = null; + + /** + * @param string $fieldName The name of the field in the entity + * the association is mapped to. + * @param class-string $sourceEntity The class name of the source entity. + * In the case of to-many associations + * initially present in mapped + * superclasses, the nearest + * entity subclasses will be + * considered the respective source + * entities. + * @param class-string $targetEntity The class name of the target entity. + * If it is fully-qualified it is used as + * is. If it is a simple, unqualified + * class name the namespace is assumed to + * be the same as the namespace of the + * source entity. + */ + final public function __construct( + public readonly string $fieldName, + public string $sourceEntity, + public readonly string $targetEntity, + ) { + } + + /** + * @psalm-param array{ + * fieldName: string, + * sourceEntity: class-string, + * targetEntity: class-string, + * joinTable?: mixed[]|null, + * type?: int, + * isOwningSide: bool, ...} $mappingArray + */ + public static function fromMappingArray(array $mappingArray): static + { + unset($mappingArray['isOwningSide'], $mappingArray['type']); + $mapping = new static( + $mappingArray['fieldName'], + $mappingArray['sourceEntity'], + $mappingArray['targetEntity'], + ); + unset($mappingArray['fieldName'], $mappingArray['sourceEntity'], $mappingArray['targetEntity']); + + foreach ($mappingArray as $key => $value) { + if ($key === 'joinTable') { + assert($mapping instanceof ManyToManyAssociationMapping); + + if ($value === [] || $value === null) { + continue; + } + + assert($mapping instanceof ManyToManyOwningSideMapping); + + $mapping->joinTable = JoinTableMapping::fromMappingArray($value); + + continue; + } + + if (property_exists($mapping, $key)) { + $mapping->$key = $value; + } else { + throw new OutOfRangeException('Unknown property ' . $key . ' on class ' . static::class); + } + } + + return $mapping; + } + + /** @psalm-assert-if-true AssociationOwningSideMapping $this */ + final public function isOwningSide(): bool + { + return $this instanceof AssociationOwningSideMapping; + } + + /** @psalm-assert-if-true ToOneAssociationMapping $this */ + final public function isToOne(): bool + { + return $this instanceof ToOneAssociationMapping; + } + + /** @psalm-assert-if-true ToManyAssociationMapping $this */ + final public function isToMany(): bool + { + return $this instanceof ToManyAssociationMapping; + } + + /** @psalm-assert-if-true OneToOneOwningSideMapping|ManyToOneAssociationMapping $this */ + final public function isToOneOwningSide(): bool + { + return $this->isToOne() && $this->isOwningSide(); + } + + /** @psalm-assert-if-true ManyToManyOwningSideMapping $this */ + final public function isManyToManyOwningSide(): bool + { + return $this instanceof ManyToManyOwningSideMapping; + } + + /** @psalm-assert-if-true OneToOneAssociationMapping $this */ + final public function isOneToOne(): bool + { + return $this instanceof OneToOneAssociationMapping; + } + + /** @psalm-assert-if-true ManyToManyAssociationMapping $this */ + final public function isManyToMany(): bool + { + return $this instanceof ManyToManyAssociationMapping; + } + + final public function type(): int + { + return match (true) { + $this instanceof OneToOneAssociationMapping => ClassMetadata::ONE_TO_ONE, + $this instanceof OneToManyAssociationMapping => ClassMetadata::ONE_TO_MANY, + $this instanceof ManyToOneAssociationMapping => ClassMetadata::MANY_TO_ONE, + $this instanceof ManyToManyAssociationMapping => ClassMetadata::MANY_TO_MANY, + default => throw new Exception('Cannot determine type for ' . $this::class), + }; + } + + /** @param string $offset */ + public function offsetExists(mixed $offset): bool + { + return isset($this->$offset) || in_array($offset, ['isOwningSide', 'type'], true); + } + + final public function offsetGet($offset): mixed + { + return match ($offset) { + 'isOwningSide' => $this->isOwningSide(), + 'type' => $this->type(), + 'isCascadeRemove' => $this->isCascadeRemove(), + 'isCascadePersist' => $this->isCascadePersist(), + 'isCascadeRefresh' => $this->isCascadeRefresh(), + 'isCascadeDetach' => $this->isCascadeDetach(), + 'isCascadeMerge' => $this->isCascadeMerge(), + default => property_exists($this, $offset) ? $this->$offset : throw new OutOfRangeException(sprintf( + 'Unknown property "%s" on class %s', + $offset, + static::class, + )), + }; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + assert($offset !== null); + if (! property_exists($this, $offset)) { + throw new OutOfRangeException(sprintf( + 'Unknown property "%s" on class %s', + $offset, + static::class, + )); + } + + if ($offset === 'joinTable') { + $value = JoinTableMapping::fromMappingArray($value); + } + + $this->$offset = $value; + } + + /** @param string $offset */ + public function offsetUnset(mixed $offset): void + { + if (! property_exists($this, $offset)) { + throw new OutOfRangeException(sprintf( + 'Unknown property "%s" on class %s', + $offset, + static::class, + )); + } + + $this->$offset = null; + } + + final public function isCascadeRemove(): bool + { + return in_array('remove', $this->cascade, true); + } + + final public function isCascadePersist(): bool + { + return in_array('persist', $this->cascade, true); + } + + final public function isCascadeRefresh(): bool + { + return in_array('refresh', $this->cascade, true); + } + + final public function isCascadeMerge(): bool + { + return in_array('merge', $this->cascade, true); + } + + final public function isCascadeDetach(): bool + { + return in_array('detach', $this->cascade, true); + } + + /** @return array */ + public function toArray(): array + { + $array = (array) $this; + + $array['isOwningSide'] = $this->isOwningSide(); + $array['type'] = $this->type(); + + return $array; + } + + /** @return list */ + public function __sleep(): array + { + $serialized = ['fieldName', 'sourceEntity', 'targetEntity']; + + if (count($this->cascade) > 0) { + $serialized[] = 'cascade'; + } + + foreach ( + [ + 'mappedBy', + 'inversedBy', + 'fetch', + 'inherited', + 'declared', + 'cache', + 'joinColumnFieldNames', + 'joinTableColumns', + 'originalClass', + 'originalField', + ] as $stringOrArrayProperty + ) { + if ($this->$stringOrArrayProperty !== null) { + $serialized[] = $stringOrArrayProperty; + } + } + + foreach (['id', 'orphanRemoval', 'isOnDeleteCascade', 'unique'] as $boolProperty) { + if ($this->$boolProperty) { + $serialized[] = $boolProperty; + } + } + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/AssociationOwningSideMapping.php b/lib/Doctrine/ORM/Mapping/AssociationOwningSideMapping.php new file mode 100644 index 00000000000..6230e72dedf --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/AssociationOwningSideMapping.php @@ -0,0 +1,9 @@ + - * @psalm-type JoinColumnData = array{ - * name: string, - * referencedColumnName: string, - * unique?: bool, - * quoted?: bool, - * fieldName?: string, - * onDelete?: string, - * columnDefinition?: string, - * nullable?: bool, - * } - * @psalm-type AssociationMapping = array{ - * cache?: array, - * cascade: array, - * declared?: class-string, - * fetch: mixed, - * fieldName: string, - * id?: bool, - * inherited?: class-string, - * indexBy?: string, - * inversedBy: string|null, - * isCascadeRemove: bool, - * isCascadePersist: bool, - * isCascadeRefresh: bool, - * isCascadeMerge: bool, - * isCascadeDetach: bool, - * isOnDeleteCascade?: bool, - * isOwningSide: bool, - * joinColumns?: array, - * joinColumnFieldNames?: array, - * joinTable?: array, - * joinTableColumns?: list, - * mappedBy: string|null, - * orderBy?: array, - * originalClass?: class-string, - * originalField?: string, - * orphanRemoval?: bool, - * relationToSourceKeyColumns?: array, - * relationToTargetKeyColumns?: array, - * sourceEntity: class-string, - * sourceToTargetKeyColumns?: array, - * targetEntity: class-string, - * targetToSourceKeyColumns?: array, - * type: int, - * unique?: bool, * } */ class ClassMetadata implements PersistenceClassMetadata, Stringable @@ -477,66 +429,6 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable /** * READ-ONLY: The association mappings of this class. * - * The mapping definition array supports the following keys: - * - * - fieldName (string) - * The name of the field in the entity the association is mapped to. - * - * - sourceEntity (string) - * The class name of the source entity. In the case of to-many associations initially - * present in mapped superclasses, the nearest entity subclasses will be - * considered the respective source entities. - * - * - targetEntity (string) - * The class name of the target entity. If it is fully-qualified it is used as is. - * If it is a simple, unqualified class name the namespace is assumed to be the same - * as the namespace of the source entity. - * - * - mappedBy (string, required for bidirectional associations) - * The name of the field that completes the bidirectional association on the owning side. - * This key must be specified on the inverse side of a bidirectional association. - * - * - inversedBy (string, required for bidirectional associations) - * The name of the field that completes the bidirectional association on the inverse side. - * This key must be specified on the owning side of a bidirectional association. - * - * - cascade (array, optional) - * The names of persistence operations to cascade on the association. The set of possible - * values are: "persist", "remove", "detach", "merge", "refresh", "all" (implies all others). - * - * - orderBy (array, one-to-many/many-to-many only) - * A map of field names (of the target entity) to sorting directions (ASC/DESC). - * Example: array('priority' => 'desc') - * - * - fetch (integer, optional) - * The fetching strategy to use for the association, usually defaults to FETCH_LAZY. - * Possible values are: ClassMetadata::FETCH_EAGER, ClassMetadata::FETCH_LAZY. - * - * - joinTable (array, optional, many-to-many only) - * Specification of the join table and its join columns (foreign keys). - * Only valid for many-to-many mappings. Note that one-to-many associations can be mapped - * through a join table by simply mapping the association as many-to-many with a unique - * constraint on the join table. - * - * - indexBy (string, optional, to-many only) - * Specification of a field on target-entity that is used to index the collection by. - * This field HAS to be either the primary key or a unique column. Otherwise the collection - * does not contain all the entities that are actually related. - * - * - 'inherited' (string, optional) - * This is set when the association is inherited by this class from another (inheritance) parent - * entity class. The value is the FQCN of the topmost entity class that contains - * this association. (If there are transient classes in the - * class hierarchy, these are ignored, so the class property may in fact come - * from a class further up in the PHP class hierarchy.) - * To-many associations initially declared in mapped superclasses are - * not considered 'inherited' in the nearest entity subclasses. - * - * - 'declared' (string, optional) - * This is set when the association does not appear in the current class for the first time, but - * is initially declared in another parent entity or mapped superclass. The value is the FQCN - * of the topmost non-transient class that contains association information for this relationship. - * * A join table definition has the following structure: *
      * array(
@@ -546,7 +438,7 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
      * )
      * 
* - * @psalm-var array + * @psalm-var array */ public array $associationMappings = []; @@ -1185,12 +1077,9 @@ public function getFieldMapping(string $fieldName): FieldMapping * @param string $fieldName The field name that represents the association in * the object model. * - * @return mixed[] The mapping. - * @psalm-return AssociationMapping - * * @throws MappingException */ - public function getAssociationMapping(string $fieldName): array + public function getAssociationMapping(string $fieldName): AssociationMapping { if (! isset($this->associationMappings[$fieldName])) { throw MappingException::mappingNotFound($this->name, $fieldName); @@ -1364,12 +1253,9 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping * * @psalm-param array $mapping The mapping. * - * @return mixed[] The updated mapping. - * @psalm-return AssociationMapping - * * @throws MappingException If something is wrong with the mapping. */ - protected function _validateAndCompleteAssociationMapping(array $mapping): array + protected function _validateAndCompleteAssociationMapping(array $mapping): AssociationMapping { if (! isset($mapping['mappedBy'])) { $mapping['mappedBy'] = null; @@ -1447,7 +1333,7 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): array // Mandatory and optional attributes for either side if (! $mapping['mappedBy']) { - if (isset($mapping['joinTable']) && $mapping['joinTable']) { + if (isset($mapping['joinTable'])) { if (isset($mapping['joinTable']['name']) && $mapping['joinTable']['name'][0] === '`') { $mapping['joinTable']['name'] = trim($mapping['joinTable']['name'], '`'); $mapping['joinTable']['quoted'] = true; @@ -1480,239 +1366,58 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): array ); } - $mapping['cascade'] = $cascades; - $mapping['isCascadeRemove'] = in_array('remove', $cascades, true); - $mapping['isCascadePersist'] = in_array('persist', $cascades, true); - $mapping['isCascadeRefresh'] = in_array('refresh', $cascades, true); - $mapping['isCascadeMerge'] = in_array('merge', $cascades, true); - $mapping['isCascadeDetach'] = in_array('detach', $cascades, true); - - return $mapping; - } - - /** - * Validates & completes a one-to-one association mapping. - * - * @psalm-param array $mapping The mapping to validate & complete. - * - * @return mixed[] The validated & completed mapping. - * @psalm-return AssociationMapping - * - * @throws RuntimeException - * @throws MappingException - */ - protected function _validateAndCompleteOneToOneMapping(array $mapping): array - { - $mapping = $this->_validateAndCompleteAssociationMapping($mapping); - - if (isset($mapping['joinColumns']) && $mapping['joinColumns']) { - $mapping['isOwningSide'] = true; - } - - if ($mapping['isOwningSide']) { - if (empty($mapping['joinColumns'])) { - // Apply default join column - $mapping['joinColumns'] = [ - [ - 'name' => $this->namingStrategy->joinColumnName($mapping['fieldName'], $this->name), - 'referencedColumnName' => $this->namingStrategy->referenceColumnName(), - ], - ]; - } - - $uniqueConstraintColumns = []; - - foreach ($mapping['joinColumns'] as &$joinColumn) { - if ($mapping['type'] === self::ONE_TO_ONE && ! $this->isInheritanceTypeSingleTable()) { - if (count($mapping['joinColumns']) === 1) { - if (empty($mapping['id'])) { - $joinColumn['unique'] = true; - } - } else { - $uniqueConstraintColumns[] = $joinColumn['name']; - } - } - - if (empty($joinColumn['name'])) { - $joinColumn['name'] = $this->namingStrategy->joinColumnName($mapping['fieldName'], $this->name); - } - - if (empty($joinColumn['referencedColumnName'])) { - $joinColumn['referencedColumnName'] = $this->namingStrategy->referenceColumnName(); - } - - if ($joinColumn['name'][0] === '`') { - $joinColumn['name'] = trim($joinColumn['name'], '`'); - $joinColumn['quoted'] = true; - } - - if ($joinColumn['referencedColumnName'][0] === '`') { - $joinColumn['referencedColumnName'] = trim($joinColumn['referencedColumnName'], '`'); - $joinColumn['quoted'] = true; - } - - $mapping['sourceToTargetKeyColumns'][$joinColumn['name']] = $joinColumn['referencedColumnName']; - $mapping['joinColumnFieldNames'][$joinColumn['name']] = $joinColumn['fieldName'] ?? $joinColumn['name']; - } - - if ($uniqueConstraintColumns) { - if (! $this->table) { - throw new RuntimeException('ClassMetadata::setTable() has to be called before defining a one to one relationship.'); - } - - $this->table['uniqueConstraints'][$mapping['fieldName'] . '_uniq'] = ['columns' => $uniqueConstraintColumns]; - } - - $mapping['targetToSourceKeyColumns'] = array_flip($mapping['sourceToTargetKeyColumns']); - } - - $mapping['orphanRemoval'] = isset($mapping['orphanRemoval']) && $mapping['orphanRemoval']; - $mapping['isCascadeRemove'] = $mapping['orphanRemoval'] || $mapping['isCascadeRemove']; - - if ($mapping['orphanRemoval']) { - unset($mapping['unique']); - } - - if (isset($mapping['id']) && $mapping['id'] === true && ! $mapping['isOwningSide']) { - throw MappingException::illegalInverseIdentifierAssociation($this->name, $mapping['fieldName']); - } - - return $mapping; - } - - /** - * Validates & completes a one-to-many association mapping. - * - * @psalm-param array $mapping The mapping to validate and complete. - * - * @return mixed[] The validated and completed mapping. - * @psalm-return AssociationMapping - * - * @throws MappingException - * @throws InvalidArgumentException - */ - protected function _validateAndCompleteOneToManyMapping(array $mapping): array - { - $mapping = $this->_validateAndCompleteAssociationMapping($mapping); - - // OneToMany-side MUST be inverse (must have mappedBy) - if (! isset($mapping['mappedBy'])) { - throw MappingException::oneToManyRequiresMappedBy($this->name, $mapping['fieldName']); - } - - $mapping['orphanRemoval'] = isset($mapping['orphanRemoval']) && $mapping['orphanRemoval']; - $mapping['isCascadeRemove'] = $mapping['orphanRemoval'] || $mapping['isCascadeRemove']; - - $this->assertMappingOrderBy($mapping); - - return $mapping; - } - - /** - * Validates & completes a many-to-many association mapping. - * - * @psalm-param array $mapping The mapping to validate & complete. - * - * @return mixed[] The validated & completed mapping. - * @psalm-return AssociationMapping - * - * @throws InvalidArgumentException - */ - protected function _validateAndCompleteManyToManyMapping(array $mapping): array - { - $mapping = $this->_validateAndCompleteAssociationMapping($mapping); - - if ($mapping['isOwningSide']) { - // owning side MUST have a join table - if (! isset($mapping['joinTable']['name'])) { - $mapping['joinTable']['name'] = $this->namingStrategy->joinTableName($mapping['sourceEntity'], $mapping['targetEntity'], $mapping['fieldName']); - } - - $selfReferencingEntityWithoutJoinColumns = $mapping['sourceEntity'] === $mapping['targetEntity'] - && (! (isset($mapping['joinTable']['joinColumns']) || isset($mapping['joinTable']['inverseJoinColumns']))); - - if (! isset($mapping['joinTable']['joinColumns'])) { - $mapping['joinTable']['joinColumns'] = [ - [ - 'name' => $this->namingStrategy->joinKeyColumnName($mapping['sourceEntity'], $selfReferencingEntityWithoutJoinColumns ? 'source' : null), - 'referencedColumnName' => $this->namingStrategy->referenceColumnName(), - 'onDelete' => 'CASCADE', - ], - ]; - } - - if (! isset($mapping['joinTable']['inverseJoinColumns'])) { - $mapping['joinTable']['inverseJoinColumns'] = [ - [ - 'name' => $this->namingStrategy->joinKeyColumnName($mapping['targetEntity'], $selfReferencingEntityWithoutJoinColumns ? 'target' : null), - 'referencedColumnName' => $this->namingStrategy->referenceColumnName(), - 'onDelete' => 'CASCADE', - ], - ]; - } - - $mapping['joinTableColumns'] = []; - - foreach ($mapping['joinTable']['joinColumns'] as &$joinColumn) { - if (empty($joinColumn['name'])) { - $joinColumn['name'] = $this->namingStrategy->joinKeyColumnName($mapping['sourceEntity'], $joinColumn['referencedColumnName']); - } - - if (empty($joinColumn['referencedColumnName'])) { - $joinColumn['referencedColumnName'] = $this->namingStrategy->referenceColumnName(); - } - - if ($joinColumn['name'][0] === '`') { - $joinColumn['name'] = trim($joinColumn['name'], '`'); - $joinColumn['quoted'] = true; - } + $mapping['cascade'] = $cascades; - if ($joinColumn['referencedColumnName'][0] === '`') { - $joinColumn['referencedColumnName'] = trim($joinColumn['referencedColumnName'], '`'); - $joinColumn['quoted'] = true; - } - - if (isset($joinColumn['onDelete']) && strtolower($joinColumn['onDelete']) === 'cascade') { - $mapping['isOnDeleteCascade'] = true; + switch ($mapping['type']) { + case self::ONE_TO_ONE: + if (isset($mapping['joinColumns']) && $mapping['joinColumns']) { + $mapping['isOwningSide'] = true; } - $mapping['relationToSourceKeyColumns'][$joinColumn['name']] = $joinColumn['referencedColumnName']; - $mapping['joinTableColumns'][] = $joinColumn['name']; - } - - foreach ($mapping['joinTable']['inverseJoinColumns'] as &$inverseJoinColumn) { - if (empty($inverseJoinColumn['name'])) { - $inverseJoinColumn['name'] = $this->namingStrategy->joinKeyColumnName($mapping['targetEntity'], $inverseJoinColumn['referencedColumnName']); - } + return $mapping['isOwningSide'] ? + OneToOneOwningSideMapping::fromMappingArrayAndName( + $mapping, + $this->namingStrategy, + $this->name, + $this->table ?? null, + $this->isInheritanceTypeSingleTable(), + ) : + OneToOneAssociationMapping::fromMappingArrayAndName( + $mapping, + $this->namingStrategy, + $this->name, + $this->table, + $this->isInheritanceTypeSingleTable(), + ); - if (empty($inverseJoinColumn['referencedColumnName'])) { - $inverseJoinColumn['referencedColumnName'] = $this->namingStrategy->referenceColumnName(); - } + case self::MANY_TO_ONE: + return ManyToOneAssociationMapping::fromMappingArrayAndName( + $mapping, + $this->namingStrategy, + $this->name, + $this->table, + $this->isInheritanceTypeSingleTable(), + ); - if ($inverseJoinColumn['name'][0] === '`') { - $inverseJoinColumn['name'] = trim($inverseJoinColumn['name'], '`'); - $inverseJoinColumn['quoted'] = true; - } + case self::ONE_TO_MANY: + return OneToManyAssociationMapping::fromMappingArrayAndName($mapping, $this->name); - if ($inverseJoinColumn['referencedColumnName'][0] === '`') { - $inverseJoinColumn['referencedColumnName'] = trim($inverseJoinColumn['referencedColumnName'], '`'); - $inverseJoinColumn['quoted'] = true; + case self::MANY_TO_MANY: + if (isset($mapping['joinColumns'])) { + unset($mapping['joinColumns']); } - if (isset($inverseJoinColumn['onDelete']) && strtolower($inverseJoinColumn['onDelete']) === 'cascade') { - $mapping['isOnDeleteCascade'] = true; - } + return $mapping['isOwningSide'] ? + ManyToManyOwningSideMapping::fromMappingArrayAndNamingStrategy($mapping, $this->namingStrategy) : + ManyToManyAssociationMapping::fromMappingArray($mapping); - $mapping['relationToTargetKeyColumns'][$inverseJoinColumn['name']] = $inverseJoinColumn['referencedColumnName']; - $mapping['joinTableColumns'][] = $inverseJoinColumn['name']; - } + default: + throw MappingException::invalidAssociationType( + $this->name, + $mapping['fieldName'], + $mapping['type'], + ); } - - $mapping['orphanRemoval'] = isset($mapping['orphanRemoval']) && $mapping['orphanRemoval']; - - $this->assertMappingOrderBy($mapping); - - return $mapping; } /** @@ -1814,7 +1519,7 @@ public function getIdentifierColumnNames(): array // Association defined as Id field $joinColumns = $this->associationMappings[$idProperty]['joinColumns']; - $assocColumnNames = array_map(static fn ($joinColumn) => $joinColumn['name'], $joinColumns); + $assocColumnNames = array_map(static fn (JoinColumnMapping $joinColumn): string => $joinColumn['name'], $joinColumns); $columnNames = array_merge($columnNames, $assocColumnNames); } @@ -1989,7 +1694,7 @@ public function setAssociationOverride(string $fieldName, array $overrideMapping throw MappingException::invalidOverrideFieldName($this->name, $fieldName); } - $mapping = $this->associationMappings[$fieldName]; + $mapping = $this->associationMappings[$fieldName]->toArray(); //if (isset($mapping['inherited']) && (count($overrideMapping) !== 1 || ! isset($overrideMapping['fetch']))) { // TODO: Deprecate overriding the fetch mode via association override for 3.0, @@ -2014,28 +1719,21 @@ public function setAssociationOverride(string $fieldName, array $overrideMapping $mapping['fetch'] = $overrideMapping['fetch']; } - $mapping['joinColumnFieldNames'] = null; - $mapping['joinTableColumns'] = null; - $mapping['sourceToTargetKeyColumns'] = null; - $mapping['relationToSourceKeyColumns'] = null; - $mapping['relationToTargetKeyColumns'] = null; + $mapping['joinColumnFieldNames'] = null; + $mapping['joinTableColumns'] = null; switch ($mapping['type']) { case self::ONE_TO_ONE: - $mapping = $this->_validateAndCompleteOneToOneMapping($mapping); - break; - case self::ONE_TO_MANY: - $mapping = $this->_validateAndCompleteOneToManyMapping($mapping); - break; case self::MANY_TO_ONE: - $mapping = $this->_validateAndCompleteOneToOneMapping($mapping); + $mapping['sourceToTargetKeyColumns'] = null; break; case self::MANY_TO_MANY: - $mapping = $this->_validateAndCompleteManyToManyMapping($mapping); + $mapping['relationToSourceKeyColumns'] = null; + $mapping['relationToTargetKeyColumns'] = null; break; } - $this->associationMappings[$fieldName] = $mapping; + $this->associationMappings[$fieldName] = $this->_validateAndCompleteAssociationMapping($mapping); } /** @@ -2204,11 +1902,9 @@ public function mapField(array $mapping): void * Adds an association mapping without completing/validating it. * This is mainly used to add inherited association mappings to derived classes. * - * @psalm-param AssociationMapping $mapping - * * @throws MappingException */ - public function addInheritedAssociationMapping(array $mapping/*, $owningClassName = null*/): void + public function addInheritedAssociationMapping(AssociationMapping $mapping/*, $owningClassName = null*/): void { if (isset($this->associationMappings[$mapping['fieldName']])) { throw MappingException::duplicateAssociationMapping($this->name, $mapping['fieldName']); @@ -2238,7 +1934,7 @@ public function mapOneToOne(array $mapping): void { $mapping['type'] = self::ONE_TO_ONE; - $mapping = $this->_validateAndCompleteOneToOneMapping($mapping); + $mapping = $this->_validateAndCompleteAssociationMapping($mapping); $this->_storeAssociationMapping($mapping); } @@ -2252,7 +1948,7 @@ public function mapOneToMany(array $mapping): void { $mapping['type'] = self::ONE_TO_MANY; - $mapping = $this->_validateAndCompleteOneToManyMapping($mapping); + $mapping = $this->_validateAndCompleteAssociationMapping($mapping); $this->_storeAssociationMapping($mapping); } @@ -2266,8 +1962,7 @@ public function mapManyToOne(array $mapping): void { $mapping['type'] = self::MANY_TO_ONE; - // A many-to-one mapping is essentially a one-one backreference - $mapping = $this->_validateAndCompleteOneToOneMapping($mapping); + $mapping = $this->_validateAndCompleteAssociationMapping($mapping); $this->_storeAssociationMapping($mapping); } @@ -2281,7 +1976,7 @@ public function mapManyToMany(array $mapping): void { $mapping['type'] = self::MANY_TO_MANY; - $mapping = $this->_validateAndCompleteManyToManyMapping($mapping); + $mapping = $this->_validateAndCompleteAssociationMapping($mapping); $this->_storeAssociationMapping($mapping); } @@ -2289,11 +1984,9 @@ public function mapManyToMany(array $mapping): void /** * Stores the association mapping. * - * @psalm-param array $assocMapping - * * @throws MappingException */ - protected function _storeAssociationMapping(array $assocMapping): void + protected function _storeAssociationMapping(AssociationMapping $assocMapping): void { $sourceFieldName = $assocMapping['fieldName']; @@ -2538,13 +2231,13 @@ public function hasAssociation(string $fieldName): bool public function isSingleValuedAssociation(string $fieldName): bool { return isset($this->associationMappings[$fieldName]) - && ($this->associationMappings[$fieldName]['type'] & self::TO_ONE); + && ($this->associationMappings[$fieldName]->isToOne()); } public function isCollectionValuedAssociation(string $fieldName): bool { return isset($this->associationMappings[$fieldName]) - && ! ($this->associationMappings[$fieldName]['type'] & self::TO_ONE); + && ! $this->associationMappings[$fieldName]->isToOne(); } /** @@ -2911,14 +2604,6 @@ public function getSequencePrefix(AbstractPlatform $platform): string return $sequencePrefix; } - /** @psalm-param array $mapping */ - private function assertMappingOrderBy(array $mapping): void - { - if (isset($mapping['orderBy']) && ! is_array($mapping['orderBy'])) { - throw new InvalidArgumentException("'orderBy' is expected to be an array, not " . gettype($mapping['orderBy'])); - } - } - /** @psalm-param class-string $class */ private function getAccessibleProperty(ReflectionService $reflService, string $class, string $field): ReflectionProperty|null { diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php index 3b0a50e69a9..63edd9df5a4 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php @@ -45,7 +45,6 @@ * to a relational database. * * @extends AbstractClassMetadataFactory - * @psalm-import-type AssociationMapping from ClassMetadata */ class ClassMetadataFactory extends AbstractClassMetadataFactory { @@ -385,7 +384,7 @@ private function getShortName(string $className): string * and embedded classes. */ private function addMappingInheritanceInformation( - array|EmbeddedClassMapping|FieldMapping &$mapping, + AssociationMapping|EmbeddedClassMapping|FieldMapping $mapping, ClassMetadata $parentClass, ): void { if (! isset($mapping['inherited']) && ! $parentClass->isMappedSuperclass) { @@ -421,18 +420,19 @@ private function addInheritedFields(ClassMetadata $subClass, ClassMetadata $pare private function addInheritedRelations(ClassMetadata $subClass, ClassMetadata $parentClass): void { foreach ($parentClass->associationMappings as $field => $mapping) { - $this->addMappingInheritanceInformation($mapping, $parentClass); + $subClassMapping = clone $mapping; + $this->addMappingInheritanceInformation($subClassMapping, $parentClass); // When the class inheriting the relation ($subClass) is the first entity class since the // relation has been defined in a mapped superclass (or in a chain // of mapped superclasses) above, then declare this current entity class as the source of // the relationship. // According to the definitions given in https://github.com/doctrine/orm/pull/10396/, // this is the case <=> ! isset($mapping['inherited']). - if (! isset($mapping['inherited'])) { - $mapping['sourceEntity'] = $subClass->name; + if (! isset($subClassMapping['inherited'])) { + $subClassMapping['sourceEntity'] = $subClass->name; } - $subClass->addInheritedAssociationMapping($mapping); + $subClass->addInheritedAssociationMapping($subClassMapping); } } diff --git a/lib/Doctrine/ORM/Mapping/DefaultQuoteStrategy.php b/lib/Doctrine/ORM/Mapping/DefaultQuoteStrategy.php index 86df5209054..0b29cc95d3b 100644 --- a/lib/Doctrine/ORM/Mapping/DefaultQuoteStrategy.php +++ b/lib/Doctrine/ORM/Mapping/DefaultQuoteStrategy.php @@ -55,21 +55,15 @@ public function getSequenceName(array $definition, ClassMetadata $class, Abstrac : $definition['sequenceName']; } - /** - * {@inheritdoc} - */ - public function getJoinColumnName(array $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string + public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string { return isset($joinColumn['quoted']) ? $platform->quoteIdentifier($joinColumn['name']) : $joinColumn['name']; } - /** - * {@inheritdoc} - */ public function getReferencedJoinColumnName( - array $joinColumn, + JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform, ): string { @@ -78,11 +72,11 @@ public function getReferencedJoinColumnName( : $joinColumn['referencedColumnName']; } - /** - * {@inheritdoc} - */ - public function getJoinTableName(array $association, ClassMetadata $class, AbstractPlatform $platform): string - { + public function getJoinTableName( + AssociationMapping $association, + ClassMetadata $class, + AbstractPlatform $platform, + ): string { $schema = ''; if (isset($association['joinTable']['schema'])) { @@ -115,7 +109,7 @@ public function getIdentifierColumnNames(ClassMetadata $class, AbstractPlatform // Association defined as Id field $joinColumns = $class->associationMappings[$fieldName]['joinColumns']; $assocQuotedColumnNames = array_map( - static fn (array $joinColumn) => isset($joinColumn['quoted']) + static fn (JoinColumnMapping $joinColumn) => isset($joinColumn['quoted']) ? $platform->quoteIdentifier($joinColumn['name']) : $joinColumn['name'], $joinColumns, diff --git a/lib/Doctrine/ORM/Mapping/JoinColumnMapping.php b/lib/Doctrine/ORM/Mapping/JoinColumnMapping.php new file mode 100644 index 00000000000..37ea4728b61 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/JoinColumnMapping.php @@ -0,0 +1,67 @@ + */ +final class JoinColumnMapping implements ArrayAccess +{ + use ArrayAccessImplementation; + + public string|null $name = null; + public bool|null $unique = null; + public bool|null $quoted = null; + public string|null $fieldName = null; + public string|null $onDelete = null; + public string|null $columnDefinition = null; + public bool|null $nullable = null; + + /** @var array|null */ + public array|null $options = null; + + public function __construct( + public string $referencedColumnName, + ) { + } + + /** + * @param array $mappingArray + * @psalm-param array{name: string, referencedColumnName: string, ...} $mappingArray + */ + public static function fromMappingArray(array $mappingArray): self + { + $mapping = new self($mappingArray['referencedColumnName']); + foreach ($mappingArray as $key => $value) { + if (property_exists($mapping, $key) && $value !== null) { + $mapping->$key = $value; + } + } + + return $mapping; + } + + /** @return list */ + public function __sleep(): array + { + $serialized = []; + + foreach (['name', 'fieldName', 'onDelete', 'columnDefinition', 'referencedColumnName', 'options'] as $stringOrArrayKey) { + if ($this->$stringOrArrayKey !== null) { + $serialized[] = $stringOrArrayKey; + } + } + + foreach (['unique', 'quoted', 'nullable'] as $boolKey) { + if ($this->$boolKey !== null) { + $serialized[] = $boolKey; + } + } + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/JoinTableMapping.php b/lib/Doctrine/ORM/Mapping/JoinTableMapping.php new file mode 100644 index 00000000000..30283ee9c8a --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/JoinTableMapping.php @@ -0,0 +1,100 @@ + */ +final class JoinTableMapping implements ArrayAccess +{ + use ArrayAccessImplementation; + + public bool|null $quoted = null; + + /** @var list */ + public array $joinColumns = []; + + /** @var list */ + public array $inverseJoinColumns = []; + + public string|null $schema = null; + + public string|null $name = null; + + /** @param array{name?: string, quoted?: bool, joinColumns?: mixed[], inverseJoinColumns?: mixed[], schema?: string} $mappingArray */ + public static function fromMappingArray(array $mappingArray): self + { + $mapping = new self(); + + foreach (['name', 'quoted', 'schema'] as $key) { + if (isset($mappingArray[$key])) { + $mapping[$key] = $mappingArray[$key]; + } + } + + if (isset($mappingArray['joinColumns'])) { + foreach ($mappingArray['joinColumns'] as $column) { + $mapping->joinColumns[] = JoinColumnMapping::fromMappingArray($column); + } + } + + if (isset($mappingArray['inverseJoinColumns'])) { + foreach ($mappingArray['inverseJoinColumns'] as $column) { + $mapping->inverseJoinColumns[] = JoinColumnMapping::fromMappingArray($column); + } + } + + return $mapping; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (in_array($offset, ['joinColumns', 'inverseJoinColumns'], true)) { + $joinColumns = []; + foreach ($value as $column) { + $joinColumns[] = JoinColumnMapping::fromMappingArray($column); + } + + $value = $joinColumns; + } + + $this->$offset = $value; + } + + /** @return mixed[] */ + public function toArray(): array + { + $array = (array) $this; + + $toArray = static fn (JoinColumnMapping $column): array => (array) $column; + $array['joinColumns'] = array_map($toArray, $array['joinColumns']); + $array['inverseJoinColumns'] = array_map($toArray, $array['inverseJoinColumns']); + + return $array; + } + + /** @return list */ + public function __sleep(): array + { + $serialized = []; + + foreach (['joinColumns', 'inverseJoinColumns', 'name', 'schema'] as $stringOrArrayKey) { + if ($this->$stringOrArrayKey !== null) { + $serialized[] = $stringOrArrayKey; + } + } + + foreach (['quoted'] as $boolKey) { + if ($this->$boolKey) { + $serialized[] = $boolKey; + } + } + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/ManyToManyAssociationMapping.php b/lib/Doctrine/ORM/Mapping/ManyToManyAssociationMapping.php new file mode 100644 index 00000000000..d2d2666e60f --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ManyToManyAssociationMapping.php @@ -0,0 +1,25 @@ + */ + public function __sleep(): array + { + $serialized = parent::__sleep(); + + foreach (['relationToSourceKeyColumns', 'relationToTargetKeyColumns'] as $arrayKey) { + if ($this->$arrayKey !== null) { + $serialized[] = $arrayKey; + } + } + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/ManyToManyOwningSideMapping.php b/lib/Doctrine/ORM/Mapping/ManyToManyOwningSideMapping.php new file mode 100644 index 00000000000..0c88018f239 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ManyToManyOwningSideMapping.php @@ -0,0 +1,136 @@ + */ + public function toArray(): array + { + $array = parent::toArray(); + + $array['joinTable'] = $this->joinTable->toArray(); + + return $array; + } + + /** @param mixed[] $mappingArray */ + public static function fromMappingArrayAndNamingStrategy(array $mappingArray, NamingStrategy $namingStrategy): self + { + $mapping = parent::fromMappingArray($mappingArray); + + // owning side MUST have a join table + if (! isset($mapping->joinTable->name)) { + $mapping->joinTable ??= new JoinTableMapping(); + $mapping->joinTable->name = $namingStrategy->joinTableName( + $mapping->sourceEntity, + $mapping->targetEntity, + $mapping->fieldName, + ); + } + + $selfReferencingEntityWithoutJoinColumns = $mapping->sourceEntity === $mapping->targetEntity + && $mapping->joinTable->joinColumns === [] + && $mapping->joinTable->inverseJoinColumns === []; + + if ($mapping->joinTable->joinColumns === []) { + $mapping->joinTable->joinColumns = [ + JoinColumnMapping::fromMappingArray([ + 'name' => $namingStrategy->joinKeyColumnName($mapping->sourceEntity, $selfReferencingEntityWithoutJoinColumns ? 'source' : null), + 'referencedColumnName' => $namingStrategy->referenceColumnName(), + 'onDelete' => 'CASCADE', + ]), + ]; + } + + if ($mapping->joinTable->inverseJoinColumns === []) { + $mapping->joinTable->inverseJoinColumns = [ + JoinColumnMapping::fromMappingArray([ + 'name' => $namingStrategy->joinKeyColumnName($mapping->targetEntity, $selfReferencingEntityWithoutJoinColumns ? 'target' : null), + 'referencedColumnName' => $namingStrategy->referenceColumnName(), + 'onDelete' => 'CASCADE', + ]), + ]; + } + + $mapping->joinTableColumns = []; + + foreach ($mapping->joinTable->joinColumns as $joinColumn) { + if (empty($joinColumn->name)) { + $joinColumn->name = $namingStrategy->joinKeyColumnName($mapping->sourceEntity, $joinColumn->referencedColumnName); + } + + if (empty($joinColumn->referencedColumnName)) { + $joinColumn->referencedColumnName = $namingStrategy->referenceColumnName(); + } + + if ($joinColumn->name[0] === '`') { + $joinColumn->name = trim($joinColumn->name, '`'); + $joinColumn->quoted = true; + } + + if ($joinColumn->referencedColumnName[0] === '`') { + $joinColumn->referencedColumnName = trim($joinColumn->referencedColumnName, '`'); + $joinColumn->quoted = true; + } + + if (isset($joinColumn->onDelete) && strtolower($joinColumn->onDelete) === 'cascade') { + $mapping->isOnDeleteCascade = true; + } + + $mapping->relationToSourceKeyColumns[$joinColumn->name] = $joinColumn->referencedColumnName; + $mapping->joinTableColumns[] = $joinColumn->name; + } + + foreach ($mapping->joinTable->inverseJoinColumns as $inverseJoinColumn) { + if (empty($inverseJoinColumn->name)) { + $inverseJoinColumn->name = $namingStrategy->joinKeyColumnName($mapping->targetEntity, $inverseJoinColumn->referencedColumnName); + } + + if (empty($inverseJoinColumn->referencedColumnName)) { + $inverseJoinColumn->referencedColumnName = $namingStrategy->referenceColumnName(); + } + + if ($inverseJoinColumn->name[0] === '`') { + $inverseJoinColumn->name = trim($inverseJoinColumn->name, '`'); + $inverseJoinColumn->quoted = true; + } + + if ($inverseJoinColumn->referencedColumnName[0] === '`') { + $inverseJoinColumn->referencedColumnName = trim($inverseJoinColumn->referencedColumnName, '`'); + $inverseJoinColumn->quoted = true; + } + + if (isset($inverseJoinColumn->onDelete) && strtolower($inverseJoinColumn->onDelete) === 'cascade') { + $mapping->isOnDeleteCascade = true; + } + + $mapping->relationToTargetKeyColumns[$inverseJoinColumn->name] = $inverseJoinColumn->referencedColumnName; + $mapping->joinTableColumns[] = $inverseJoinColumn->name; + } + + return $mapping; + } + + /** @return list */ + public function __sleep(): array + { + $serialized = parent::__sleep(); + $serialized[] = 'joinTable'; + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/ManyToOneAssociationMapping.php b/lib/Doctrine/ORM/Mapping/ManyToOneAssociationMapping.php new file mode 100644 index 00000000000..501f66b5893 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ManyToOneAssociationMapping.php @@ -0,0 +1,24 @@ + */ + public array $joinColumns = []; + + /** @return list */ + public function __sleep(): array + { + $serialized = parent::__sleep(); + + $serialized[] = 'joinColumns'; + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/MappingException.php b/lib/Doctrine/ORM/Mapping/MappingException.php index 801ba83b272..b172c174c26 100644 --- a/lib/Doctrine/ORM/Mapping/MappingException.php +++ b/lib/Doctrine/ORM/Mapping/MappingException.php @@ -44,6 +44,16 @@ public static function identifierRequired(string $entityName): self )); } + public static function invalidAssociationType(string $entityName, string $fieldName, int $type): self + { + return new self(sprintf( + 'The association "%s#%s" must be of type "ClassMetadata::ONE_TO_MANY", "ClassMetadata::MANY_TO_MANY" or "ClassMetadata::MANY_TO_ONE", "%d" given.', + $entityName, + $fieldName, + $type, + )); + } + public static function invalidInheritanceType(string $entityName, int $type): self { return new self(sprintf("The inheritance type '%s' specified for '%s' does not exist.", $type, $entityName)); diff --git a/lib/Doctrine/ORM/Mapping/OneToManyAssociationMapping.php b/lib/Doctrine/ORM/Mapping/OneToManyAssociationMapping.php new file mode 100644 index 00000000000..6ff2bbf9909 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/OneToManyAssociationMapping.php @@ -0,0 +1,33 @@ +orphanRemoval && ! $mapping->isCascadeRemove()) { + $mapping->cascade[] = 'remove'; + } + + return $mapping; + } + + /** @param mixed[] $mappingArray */ + public static function fromMappingArrayAndName(array $mappingArray, string $name): static + { + $mapping = self::fromMappingArray($mappingArray); + + // OneToMany-side MUST be inverse (must have mappedBy) + if (! isset($mapping->mappedBy)) { + throw MappingException::oneToManyRequiresMappedBy($name, $mapping->fieldName); + } + + return $mapping; + } +} diff --git a/lib/Doctrine/ORM/Mapping/OneToOneAssociationMapping.php b/lib/Doctrine/ORM/Mapping/OneToOneAssociationMapping.php new file mode 100644 index 00000000000..6faafc3ee51 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/OneToOneAssociationMapping.php @@ -0,0 +1,9 @@ + */ + public array $joinColumns = []; + + /** @return list */ + public function __sleep(): array + { + $serialized = parent::__sleep(); + + $serialized[] = 'joinColumns'; + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/QuoteStrategy.php b/lib/Doctrine/ORM/Mapping/QuoteStrategy.php index 0edfd59dc90..7b553222c85 100644 --- a/lib/Doctrine/ORM/Mapping/QuoteStrategy.php +++ b/lib/Doctrine/ORM/Mapping/QuoteStrategy.php @@ -8,9 +8,6 @@ /** * A set of rules for determining the column, alias and table quotes. - * - * @psalm-import-type AssociationMapping from ClassMetadata - * @psalm-import-type JoinColumnData from ClassMetadata */ interface QuoteStrategy { @@ -31,27 +28,19 @@ public function getTableName(ClassMetadata $class, AbstractPlatform $platform): */ public function getSequenceName(array $definition, ClassMetadata $class, AbstractPlatform $platform): string; - /** - * Gets the (possibly quoted) name of the join table. - * - * @param AssociationMapping $association - */ - public function getJoinTableName(array $association, ClassMetadata $class, AbstractPlatform $platform): string; + /** Gets the (possibly quoted) name of the join table. */ + public function getJoinTableName(AssociationMapping $association, ClassMetadata $class, AbstractPlatform $platform): string; /** * Gets the (possibly quoted) join column name. - * - * @param JoinColumnData $joinColumn */ - public function getJoinColumnName(array $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string; + public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string; /** * Gets the (possibly quoted) join column name. - * - * @param JoinColumnData $joinColumn */ public function getReferencedJoinColumnName( - array $joinColumn, + JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform, ): string; diff --git a/lib/Doctrine/ORM/Mapping/ToManyAssociationMapping.php b/lib/Doctrine/ORM/Mapping/ToManyAssociationMapping.php new file mode 100644 index 00000000000..1db301d0430 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ToManyAssociationMapping.php @@ -0,0 +1,37 @@ +|null + */ + public array|null $orderBy = null; + + /** @return list */ + public function __sleep(): array + { + $serialized = parent::__sleep(); + + foreach (['indexBy', 'orderBy'] as $stringOrArrayKey) { + if ($this->$stringOrArrayKey !== null) { + $serialized[] = $stringOrArrayKey; + } + } + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/Mapping/ToOneAssociationMapping.php b/lib/Doctrine/ORM/Mapping/ToOneAssociationMapping.php new file mode 100644 index 00000000000..5e8b74e4315 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ToOneAssociationMapping.php @@ -0,0 +1,190 @@ +|null */ + public array|null $sourceToTargetKeyColumns = null; + + /** @var array|null */ + public array|null $targetToSourceKeyColumns = null; + + /** + * @param array $mappingArray + * @psalm-param array{ + * fieldName: string, + * sourceEntity: class-string, + * targetEntity: class-string, + * joinColumns?: mixed[]|null, + * isOwningSide: bool, ...} $mappingArray + */ + public static function fromMappingArray(array $mappingArray): static + { + $joinColumns = $mappingArray['joinColumns'] ?? []; + + if (isset($mappingArray['joinColumns'])) { + unset($mappingArray['joinColumns']); + } + + $instance = parent::fromMappingArray($mappingArray); + + foreach ($joinColumns as $column) { + assert($instance->isToOneOwningSide()); + $instance->joinColumns[] = JoinColumnMapping::fromMappingArray($column); + } + + if ($instance->orphanRemoval) { + if (! $instance->isCascadeRemove()) { + $instance->cascade[] = 'remove'; + } + + $instance->unique = null; + } + + return $instance; + } + + /** + * @param mixed[] $mappingArray + * @param class-string $name + * @psalm-param array{ + * fieldName: string, + * sourceEntity: class-string, + * targetEntity: class-string, + * joinColumns?: mixed[]|null, + * isOwningSide: bool, ...} $mappingArray + */ + public static function fromMappingArrayAndName( + array $mappingArray, + NamingStrategy $namingStrategy, + string $name, + array|null $table, + bool $isInheritanceTypeSingleTable, + ): OneToOneAssociationMapping|ManyToOneAssociationMapping { + $mapping = static::fromMappingArray($mappingArray); + + if ($mapping->isOwningSide()) { + assert($mapping instanceof OneToOneOwningSideMapping || $mapping instanceof ManyToOneAssociationMapping); + if (empty($mapping->joinColumns)) { + // Apply default join column + $mapping->joinColumns = [ + JoinColumnMapping::fromMappingArray([ + 'name' => $namingStrategy->joinColumnName($mapping['fieldName'], $name), + 'referencedColumnName' => $namingStrategy->referenceColumnName(), + ]), + ]; + } + + $uniqueConstraintColumns = []; + + foreach ($mapping->joinColumns as $joinColumn) { + if ($mapping->isOneToOne() && ! $isInheritanceTypeSingleTable) { + if (count($mapping->joinColumns) === 1) { + if (empty($mapping->id)) { + $joinColumn->unique = true; + } + } else { + $uniqueConstraintColumns[] = $joinColumn->name; + } + } + + if (empty($joinColumn->name)) { + $joinColumn->name = $namingStrategy->joinColumnName($mapping->fieldName, $name); + } + + if (empty($joinColumn->referencedColumnName)) { + $joinColumn->referencedColumnName = $namingStrategy->referenceColumnName(); + } + + if ($joinColumn->name[0] === '`') { + $joinColumn->name = trim($joinColumn->name, '`'); + $joinColumn->quoted = true; + } + + if ($joinColumn->referencedColumnName[0] === '`') { + $joinColumn->referencedColumnName = trim($joinColumn->referencedColumnName, '`'); + $joinColumn->quoted = true; + } + + $mapping->sourceToTargetKeyColumns[$joinColumn->name] = $joinColumn->referencedColumnName; + $mapping->joinColumnFieldNames[$joinColumn->name] = $joinColumn->fieldName ?? $joinColumn->name; + } + + if ($uniqueConstraintColumns) { + if (! $table) { + throw new RuntimeException('ClassMetadata::setTable() has to be called before defining a one to one relationship.'); + } + + $table['uniqueConstraints'][$mapping->fieldName . '_uniq'] = ['columns' => $uniqueConstraintColumns]; + } + + $mapping->targetToSourceKeyColumns = array_flip($mapping->sourceToTargetKeyColumns); + } + + if (isset($mapping->id) && $mapping->id === true && ! $mapping->isOwningSide()) { + throw MappingException::illegalInverseIdentifierAssociation($name, $mapping->fieldName); + } + + return $mapping; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if ($offset === 'joinColumns') { + assert($this->isToOneOwningSide()); + $joinColumns = []; + foreach ($value as $column) { + $joinColumns[] = JoinColumnMapping::fromMappingArray($column); + } + + $this->joinColumns = $joinColumns; + + return; + } + + parent::offsetSet($offset, $value); + } + + /** @return array */ + public function toArray(): array + { + $array = parent::toArray(); + + if ($array['joinColumns'] !== []) { + $joinColumns = []; + foreach ($array['joinColumns'] as $column) { + $joinColumns[] = (array) $column; + } + + $array['joinColumns'] = $joinColumns; + } + + return $array; + } + + /** @return list */ + public function __sleep(): array + { + $serialized = parent::__sleep(); + + if ($this->sourceToTargetKeyColumns !== null) { + $serialized[] = 'sourceToTargetKeyColumns'; + } + + if ($this->targetToSourceKeyColumns !== null) { + $serialized[] = 'targetToSourceKeyColumns'; + } + + return $serialized; + } +} diff --git a/lib/Doctrine/ORM/ORMInvalidArgumentException.php b/lib/Doctrine/ORM/ORMInvalidArgumentException.php index 9f9d8a48cfb..2b0b146f42a 100644 --- a/lib/Doctrine/ORM/ORMInvalidArgumentException.php +++ b/lib/Doctrine/ORM/ORMInvalidArgumentException.php @@ -4,6 +4,7 @@ namespace Doctrine\ORM; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use InvalidArgumentException; use Stringable; @@ -19,8 +20,6 @@ /** * Contains exception messages for all invalid lifecycle state exceptions inside UnitOfWork - * - * @psalm-import-type AssociationMapping from ClassMetadata */ class ORMInvalidArgumentException extends InvalidArgumentException { @@ -52,10 +51,7 @@ public static function readOnlyRequiresManagedEntity(object $entity): self return new self('Only managed entities can be marked or checked as read only. But ' . self::objToStr($entity) . ' is not'); } - /** - * @psalm-param non-empty-list $newEntitiesWithAssociations non-empty an array - * of [array $associationMapping, object $entity] pairs - */ + /** @param non-empty-list $newEntitiesWithAssociations */ public static function newEntitiesFoundThroughRelationships(array $newEntitiesWithAssociations): self { $errorMessages = array_map( @@ -78,14 +74,12 @@ static function (array $newEntityWithAssociation): string { ); } - /** @psalm-param AssociationMapping $associationMapping */ - public static function newEntityFoundThroughRelationship(array $associationMapping, object $entry): self + public static function newEntityFoundThroughRelationship(AssociationMapping $associationMapping, object $entry): self { return new self(self::newEntityFoundThroughRelationshipMessage($associationMapping, $entry)); } - /** @psalm-param AssociationMapping $assoc */ - public static function detachedEntityFoundThroughRelationship(array $assoc, object $entry): self + public static function detachedEntityFoundThroughRelationship(AssociationMapping $assoc, object $entry): self { return new self('A detached entity of type ' . $assoc['targetEntity'] . ' (' . self::objToStr($entry) . ') ' . " was found through the relationship '" . $assoc['sourceEntity'] . '#' . $assoc['fieldName'] . "' " @@ -137,8 +131,7 @@ public static function invalidIdentifierBindingEntity(string $class): self )); } - /** @param AssociationMapping $assoc */ - public static function invalidAssociation(ClassMetadata $targetClass, array $assoc, mixed $actualValue): self + public static function invalidAssociation(ClassMetadata $targetClass, AssociationMapping $assoc, mixed $actualValue): self { $expectedType = $targetClass->getName(); @@ -159,8 +152,7 @@ private static function objToStr(object $obj): string return $obj instanceof Stringable ? (string) $obj : get_debug_type($obj) . '@' . spl_object_id($obj); } - /** @psalm-param AssociationMapping $associationMapping */ - private static function newEntityFoundThroughRelationshipMessage(array $associationMapping, object $entity): string + private static function newEntityFoundThroughRelationshipMessage(AssociationMapping $associationMapping, object $entity): string { return 'A new entity was found through the relationship \'' . $associationMapping['sourceEntity'] . '#' . $associationMapping['fieldName'] . '\' that was not' diff --git a/lib/Doctrine/ORM/PersistentCollection.php b/lib/Doctrine/ORM/PersistentCollection.php index 041963e6b54..41e5a886e70 100644 --- a/lib/Doctrine/ORM/PersistentCollection.php +++ b/lib/Doctrine/ORM/PersistentCollection.php @@ -9,7 +9,9 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Selectable; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; use RuntimeException; use function array_combine; @@ -34,7 +36,6 @@ * @psalm-template T * @template-extends AbstractLazyCollection * @template-implements Selectable - * @psalm-import-type AssociationMapping from ClassMetadata */ final class PersistentCollection extends AbstractLazyCollection implements Selectable { @@ -54,10 +55,8 @@ final class PersistentCollection extends AbstractLazyCollection implements Selec /** * The association mapping the collection belongs to. * This is currently either a OneToManyMapping or a ManyToManyMapping. - * - * @psalm-var AssociationMapping|null */ - private array|null $association = null; + private AssociationMapping|null $association = null; /** * The EntityManager that manages the persistence of the collection. @@ -97,10 +96,8 @@ public function __construct( * INTERNAL: * Sets the collection's owning entity together with the AssociationMapping that * describes the association between the owner and the elements of the collection. - * - * @psalm-param AssociationMapping $assoc */ - public function setOwner(object $entity, array $assoc): void + public function setOwner(object $entity, AssociationMapping $assoc): void { $this->owner = $entity; $this->association = $assoc; @@ -137,6 +134,7 @@ private function getUnitOfWork(): UnitOfWork */ public function hydrateAdd(mixed $element): void { + assert($this->association !== null); $this->unwrap()->add($element); // If _backRefFieldName is set and its a one-to-many association, @@ -167,7 +165,7 @@ public function hydrateSet(mixed $key, mixed $element): void // If _backRefFieldName is set, then the association is bidirectional // and we need to set the back reference. - if ($this->backRefFieldName && $this->association['type'] === ClassMetadata::ONE_TO_MANY) { + if ($this->backRefFieldName && $this->association !== null && $this->association['type'] === ClassMetadata::ONE_TO_MANY) { assert($this->typeClass !== null); // Set back reference to owner $this->typeClass->reflFields[$this->backRefFieldName]->setValue( @@ -245,13 +243,11 @@ public function getInsertDiff(): array )); } - /** - * INTERNAL: Gets the association mapping of the collection. - * - * @psalm-return AssociationMapping|null - */ - public function getMapping(): array|null + /** INTERNAL: Gets the association mapping of the collection. */ + public function getMapping(): AssociationMapping { + assert($this->association !== null); + return $this->association; } @@ -268,8 +264,7 @@ private function changed(): void if ( $this->association !== null && - $this->association['isOwningSide'] && - $this->association['type'] === ClassMetadata::MANY_TO_MANY && + $this->association instanceof ManyToManyOwningSideMapping && $this->owner && $this->em !== null && $this->em->getClassMetadata($this->owner::class)->isChangeTrackingNotify() @@ -319,7 +314,7 @@ public function remove(string|int $key): mixed if ( $this->association !== null && - $this->association['type'] & ClassMetadata::TO_MANY && + $this->association->isToMany() && $this->owner && $this->association['orphanRemoval'] ) { @@ -341,7 +336,7 @@ public function removeElement(mixed $element): bool if ( $this->association !== null && - $this->association['type'] & ClassMetadata::TO_MANY && + $this->association->isToMany() && $this->owner && $this->association['orphanRemoval'] ) { @@ -354,7 +349,7 @@ public function removeElement(mixed $element): bool public function containsKey(mixed $key): bool { if ( - ! $this->initialized && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY + ! $this->initialized && $this->association !== null && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY && isset($this->association['indexBy']) ) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->association); @@ -372,7 +367,7 @@ public function containsKey(mixed $key): bool */ public function contains(mixed $element): bool { - if (! $this->initialized && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY) { + if (! $this->initialized && $this->association !== null && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->association); return $this->unwrap()->contains($element) || $persister->contains($this, $element); @@ -385,6 +380,7 @@ public function get(string|int $key): mixed { if ( ! $this->initialized + && $this->association !== null && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY && isset($this->association['indexBy']) ) { @@ -474,10 +470,12 @@ public function clear(): void return; } + assert($this->association !== null); + $uow = $this->getUnitOfWork(); if ( - $this->association['type'] & ClassMetadata::TO_MANY && + $this->association->isToMany() && $this->association['orphanRemoval'] && $this->owner ) { @@ -494,7 +492,7 @@ public function clear(): void $this->initialized = true; // direct call, {@link initialize()} is too expensive - if ($this->association['isOwningSide'] && $this->owner) { + if ($this->association->isOwningSide() && $this->owner) { $this->changed(); $uow->scheduleCollectionDeletion($this); @@ -535,6 +533,7 @@ public function __wakeup(): void */ public function slice(int $offset, int|null $length = null): array { + assert($this->association !== null); if (! $this->initialized && ! $this->isDirty && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->association); @@ -587,6 +586,7 @@ public function matching(Criteria $criteria): Collection return $this->unwrap()->matching($criteria); } + assert($this->association !== null); if ($this->association['type'] === ClassMetadata::MANY_TO_MANY) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->association); diff --git a/lib/Doctrine/ORM/Persisters/Collection/ManyToManyPersister.php b/lib/Doctrine/ORM/Persisters/Collection/ManyToManyPersister.php index 3dad9bd4336..f66757b10b4 100644 --- a/lib/Doctrine/ORM/Persisters/Collection/ManyToManyPersister.php +++ b/lib/Doctrine/ORM/Persisters/Collection/ManyToManyPersister.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\DBAL\Exception as DBALException; use Doctrine\DBAL\LockMode; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Persisters\SqlValueVisitor; @@ -24,8 +25,6 @@ /** * Persister for many-to-many collections. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ class ManyToManyPersister extends AbstractCollectionPersister { @@ -276,15 +275,14 @@ public function loadCriteria(PersistentCollection $collection, Criteria $criteri * have to join in the actual entities table leading to additional * JOIN. * - * @param mixed[] $mapping Array containing mapping information. - * @psalm-param AssociationMapping $mapping + * @param AssociationMapping $mapping Array containing mapping information. * * @return string[] ordered tuple: * - JOIN condition to add to the SQL * - WHERE condition to add to the SQL * @psalm-return array{0: string, 1: string} */ - public function getFilterSql(array $mapping): array + public function getFilterSql(AssociationMapping $mapping): array { $targetClass = $this->em->getClassMetadata($mapping['targetEntity']); $rootClass = $this->em->getClassMetadata($targetClass->rootEntityName); @@ -329,13 +327,10 @@ protected function generateFilterConditionSQL(ClassMetadata $targetEntity, strin /** * Generate ON condition * - * @param mixed[] $mapping - * @psalm-param AssociationMapping $mapping - * * @return string[] * @psalm-return list */ - protected function getOnConditionSQL(array $mapping): array + protected function getOnConditionSQL(AssociationMapping $mapping): array { $targetClass = $this->em->getClassMetadata($mapping['targetEntity']); $association = ! $mapping['isOwningSide'] diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index 1b36a3d53f6..2c4e8ff6565 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -17,6 +17,7 @@ use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\QuoteStrategy; @@ -89,8 +90,6 @@ * * Subclasses can be created to provide custom persisting and querying strategies, * i.e. spanning multiple tables. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ class BasicEntityPersister implements EntityPersister { @@ -626,7 +625,7 @@ protected function prepareUpdateData(object $entity, bool $isInsert = false): ar $assoc = $this->class->associationMappings[$field]; // Only owning side of x-1 associations can have a FK column. - if (! $assoc['isOwningSide'] || ! ($assoc['type'] & ClassMetadata::TO_ONE)) { + if (! $assoc->isToOneOwningSide()) { continue; } @@ -697,7 +696,7 @@ public function getOwningTable(string $fieldName): string public function load( array $criteria, object|null $entity = null, - array|null $assoc = null, + AssociationMapping|null $assoc = null, array $hints = [], LockMode|int|null $lockMode = null, int|null $limit = null, @@ -731,7 +730,7 @@ public function loadById(array $identifier, object|null $entity = null): object| /** * {@inheritdoc} */ - public function loadOneToOneEntity(array $assoc, object $sourceEntity, array $identifier = []): object|null + public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null { $foundEntity = $this->em->getUnitOfWork()->tryGetById($identifier, $assoc['targetEntity']); if ($foundEntity !== false) { @@ -885,7 +884,7 @@ public function loadAll( * {@inheritdoc} */ public function getManyToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, @@ -900,11 +899,9 @@ public function getManyToManyCollection( /** * Loads an array of entities from a given DBAL statement. * - * @param mixed[] $assoc - * * @return mixed[] */ - private function loadArrayFromResult(array $assoc, Result $stmt): array + private function loadArrayFromResult(AssociationMapping $assoc, Result $stmt): array { $rsm = $this->currentPersisterContext->rsm; $hints = [UnitOfWork::HINT_DEFEREAGERLOAD => true]; @@ -920,12 +917,10 @@ private function loadArrayFromResult(array $assoc, Result $stmt): array /** * Hydrates a collection from a given DBAL statement. * - * @param mixed[] $assoc - * * @return mixed[] */ private function loadCollectionFromStatement( - array $assoc, + AssociationMapping $assoc, Result $stmt, PersistentCollection $coll, ): array { @@ -946,20 +941,16 @@ private function loadCollectionFromStatement( /** * {@inheritdoc} */ - public function loadManyToManyCollection(array $assoc, object $sourceEntity, PersistentCollection $collection): array + public function loadManyToManyCollection(AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection): array { $stmt = $this->getManyToManyStatement($assoc, $sourceEntity); return $this->loadCollectionFromStatement($assoc, $stmt, $collection); } - /** - * @psalm-param array $assoc - * - * @throws MappingException - */ + /** @throws MappingException */ private function getManyToManyStatement( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, @@ -1028,7 +1019,7 @@ private function getManyToManyStatement( public function getSelectSQL( array|Criteria $criteria, - array|null $assoc = null, + AssociationMapping|null $assoc = null, LockMode|int|null $lockMode = null, int|null $limit = null, int|null $offset = null, @@ -1195,14 +1186,14 @@ protected function getSelectColumnsSQL(): string $columnList[] = $assocColumnSQL; } - $isAssocToOneInverseSide = $assoc['type'] & ClassMetadata::TO_ONE && ! $assoc['isOwningSide']; - $isAssocFromOneEager = $assoc['type'] !== ClassMetadata::MANY_TO_MANY && $assoc['fetch'] === ClassMetadata::FETCH_EAGER; + $isAssocToOneInverseSide = $assoc->isToOne() && ! $assoc['isOwningSide']; + $isAssocFromOneEager = ! $assoc->isManyToMany() && $assoc['fetch'] === ClassMetadata::FETCH_EAGER; if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) { continue; } - if ((($assoc['type'] & ClassMetadata::TO_MANY) > 0) && $this->currentPersisterContext->handlesLimits) { + if (($assoc->isToMany() > 0) && $this->currentPersisterContext->handlesLimits) { continue; } @@ -1284,18 +1275,14 @@ protected function getSelectColumnsSQL(): string return $this->currentPersisterContext->selectColumnListSql; } - /** - * Gets the SQL join fragment used when selecting entities from an association. - * - * @param AssociationMapping $assoc - */ + /** Gets the SQL join fragment used when selecting entities from an association. */ protected function getSelectColumnAssociationSQL( string $field, - array $assoc, + AssociationMapping $assoc, ClassMetadata $class, string $alias = 'r', ): string { - if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) { + if (! $assoc->isToOneOwningSide()) { return ''; } @@ -1320,10 +1307,8 @@ protected function getSelectColumnAssociationSQL( /** * Gets the SQL join fragment used when selecting entities from a * many-to-many association. - * - * @psalm-param AssociationMapping $manyToMany */ - protected function getSelectManyToManyJoinSQL(array $manyToMany): string + protected function getSelectManyToManyJoinSQL(AssociationMapping $manyToMany): string { $conditions = []; $association = $manyToMany; @@ -1414,7 +1399,7 @@ protected function getInsertColumnList(): array if (isset($this->class->associationMappings[$name])) { $assoc = $this->class->associationMappings[$name]; - if ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE) { + if ($assoc->isToOneOwningSide()) { foreach ($assoc['joinColumns'] as $joinColumn) { $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); } @@ -1542,7 +1527,7 @@ protected function getSelectConditionCriteriaSQL(Criteria $criteria): string public function getSelectConditionStatementSQL( string $field, mixed $value, - array|null $assoc = null, + AssociationMapping|null $assoc = null, string|null $comparison = null, ): string { $selectedColumns = []; @@ -1612,8 +1597,6 @@ public function getSelectConditionStatementSQL( /** * Builds the left-hand-side of a where condition statement. * - * @psalm-param AssociationMapping|null $assoc - * * @return string[] * @psalm-return list * @@ -1622,7 +1605,7 @@ public function getSelectConditionStatementSQL( */ private function getSelectConditionStatementColumnSQL( string $field, - array|null $assoc = null, + AssociationMapping|null $assoc = null, ): array { if (isset($this->class->fieldMappings[$field])) { $className = $this->class->fieldMappings[$field]['inherited'] ?? $this->class->name; @@ -1642,7 +1625,8 @@ private function getSelectConditionStatementColumnSQL( } $joinTableName = $this->quoteStrategy->getJoinTableName($association, $class, $this->platform); - $joinColumns = $assoc['isOwningSide'] + assert($assoc !== null); + $joinColumns = $assoc['isOwningSide'] ? $association['joinTable']['joinColumns'] : $association['joinTable']['inverseJoinColumns']; @@ -1686,9 +1670,8 @@ private function getSelectConditionStatementColumnSQL( * or alter the criteria by which entities are selected. * * @psalm-param array $criteria - * @psalm-param AssociationMapping|null $assoc */ - protected function getSelectConditionSQL(array $criteria, array|null $assoc = null): string + protected function getSelectConditionSQL(array $criteria, AssociationMapping|null $assoc = null): string { $conditions = []; @@ -1703,7 +1686,7 @@ protected function getSelectConditionSQL(array $criteria, array|null $assoc = nu * {@inheritdoc} */ public function getOneToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, @@ -1715,11 +1698,8 @@ public function getOneToManyCollection( return $this->loadArrayFromResult($assoc, $stmt); } - /** - * {@inheritdoc} - */ public function loadOneToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection, ): mixed { @@ -1728,13 +1708,9 @@ public function loadOneToManyCollection( return $this->loadCollectionFromStatement($assoc, $stmt, $collection); } - /** - * Builds criteria and execute SQL statement to fetch the one to many entities from. - * - * @psalm-param AssociationMapping $assoc - */ + /** Builds criteria and execute SQL statement to fetch the one to many entities from. */ private function getOneToManyStatement( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, diff --git a/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php index 343f85611dc..d84cc992256 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\LockMode; use Doctrine\DBAL\ParameterType; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\PersistentCollection; @@ -16,8 +17,6 @@ /** * Entity persister interface * Define the behavior that should be implemented by all entity persisters. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ interface EntityPersister { @@ -47,14 +46,13 @@ public function getInsertSQL(): string; * Gets the SELECT SQL to select one or more entities by a set of field criteria. * * @param mixed[]|Criteria $criteria - * @param mixed[]|null $assoc * @param mixed[]|null $orderBy * @psalm-param AssociationMapping|null $assoc * @psalm-param LockMode::*|null $lockMode */ public function getSelectSQL( array|Criteria $criteria, - array|null $assoc = null, + AssociationMapping|null $assoc = null, LockMode|int|null $lockMode = null, int|null $limit = null, int|null $offset = null, @@ -84,15 +82,11 @@ public function expandParameters(array $criteria): array; */ public function expandCriteriaParameters(Criteria $criteria): array; - /** - * Gets the SQL WHERE condition for matching a field with a given value. - * - * @psalm-param AssociationMapping|null $assoc - */ + /** Gets the SQL WHERE condition for matching a field with a given value. */ public function getSelectConditionStatementSQL( string $field, mixed $value, - array|null $assoc = null, + AssociationMapping|null $assoc = null, string|null $comparison = null, ): string; @@ -158,7 +152,7 @@ public function getOwningTable(string $fieldName): string; * @param object|null $entity The entity to load the data into. If not specified, * a new entity is created. * @param AssociationMapping|null $assoc The association that connects the entity - * to load to another entity, if any. + * to load to another entity, if any. * @param mixed[] $hints Hints for entity creation. * @param LockMode|int|null $lockMode One of the \Doctrine\DBAL\LockMode::* constants * or NULL if no specific lock mode should be used @@ -177,7 +171,7 @@ public function getOwningTable(string $fieldName): string; public function load( array $criteria, object|null $entity = null, - array|null $assoc = null, + AssociationMapping|null $assoc = null, array $hints = [], LockMode|int|null $lockMode = null, int|null $limit = null, @@ -200,17 +194,17 @@ public function loadById(array $identifier, object|null $entity = null): object| * Loads an entity of this persister's mapped class as part of a single-valued * association from another entity. * - * @param object $sourceEntity The entity that owns the association (not necessarily the "owning side"). + * @param AssociationMapping $assoc The association to load. + * @param object $sourceEntity The entity that owns the association (not necessarily the "owning side"). * @psalm-param array $identifier The identifier of the entity to load. Must be provided if * the association to load represents the owning side, otherwise * the identifier is derived from the $sourceEntity. - * @psalm-param AssociationMapping $assoc The association to load. * * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. * * @throws MappingException */ - public function loadOneToOneEntity(array $assoc, object $sourceEntity, array $identifier = []): object|null; + public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null; /** * Refreshes a managed entity. @@ -250,12 +244,10 @@ public function loadAll( /** * Gets (sliced or full) elements of the given collection. * - * @psalm-param AssociationMapping $assoc - * * @return mixed[] */ public function getManyToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, @@ -264,14 +256,14 @@ public function getManyToManyCollection( /** * Loads a collection of entities of a many-to-many association. * + * @param AssociationMapping $assoc The association mapping of the association being loaded. * @param object $sourceEntity The entity that owns the collection. * @param PersistentCollection $collection The collection to fill. - * @psalm-param AssociationMapping $assoc The association mapping of the association being loaded. * * @return mixed[] */ public function loadManyToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection, ): array; @@ -280,10 +272,9 @@ public function loadManyToManyCollection( * Loads a collection of entities in a one-to-many association. * * @param PersistentCollection $collection The collection to load/fill. - * @psalm-param AssociationMapping $assoc */ public function loadOneToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection, ): mixed; @@ -299,12 +290,10 @@ public function lock(array $criteria, LockMode|int $lockMode): void; /** * Returns an array with (sliced or full list) of elements in the specified collection. * - * @psalm-param AssociationMapping $assoc - * * @return mixed[] */ public function getOneToManyCollection( - array $assoc, + AssociationMapping $assoc, object $sourceEntity, int|null $offset = null, int|null $limit = null, diff --git a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php index f4dc5610814..ec8b925b9e5 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php @@ -9,10 +9,12 @@ use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Internal\SQLResultCasing; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Utility\PersisterHelper; use function array_combine; +use function assert; use function implode; /** @@ -236,8 +238,14 @@ public function delete(object $entity): bool return (bool) $this->conn->delete($rootTable, $id, $rootTypes); } - public function getSelectSQL(array|Criteria $criteria, array|null $assoc = null, LockMode|int|null $lockMode = null, int|null $limit = null, int|null $offset = null, array|null $orderBy = null): string - { + public function getSelectSQL( + array|Criteria $criteria, + AssociationMapping|null $assoc = null, + LockMode|int|null $lockMode = null, + int|null $limit = null, + int|null $offset = null, + array|null $orderBy = null, + ): string { $this->switchPersisterContext($offset, $limit); $baseTableAlias = $this->getSQLTableAlias($this->class->name); @@ -379,7 +387,7 @@ protected function getSelectColumnsSQL(): string // Add foreign key columns foreach ($this->class->associationMappings as $mapping) { - if (! $mapping['isOwningSide'] || ! ($mapping['type'] & ClassMetadata::TO_ONE)) { + if (! $mapping->isToOneOwningSide()) { continue; } @@ -422,11 +430,7 @@ protected function getSelectColumnsSQL(): string // Add join columns (foreign keys) foreach ($subClass->associationMappings as $mapping) { - if ( - ! $mapping['isOwningSide'] - || ! ($mapping['type'] & ClassMetadata::TO_ONE) - || isset($mapping['inherited']) - ) { + if (! $mapping->isToOneOwningSide() || isset($mapping['inherited'])) { continue; } @@ -471,8 +475,9 @@ protected function getInsertColumnList(): array if (isset($this->class->associationMappings[$name])) { $assoc = $this->class->associationMappings[$name]; - if ($assoc['type'] & ClassMetadata::TO_ONE && $assoc['isOwningSide']) { - foreach ($assoc['targetToSourceKeyColumns'] as $sourceCol) { + if ($assoc->isToOneOwningSide()) { + assert($assoc->targetToSourceKeyColumns !== null); + foreach ($assoc->targetToSourceKeyColumns as $sourceCol) { $columns[] = $sourceCol; } } diff --git a/lib/Doctrine/ORM/Persisters/Entity/SingleTablePersister.php b/lib/Doctrine/ORM/Persisters/Entity/SingleTablePersister.php index 68b1d9110e5..fe5f50f94c7 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/SingleTablePersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/SingleTablePersister.php @@ -6,6 +6,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Internal\SQLResultCasing; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Utility\PersisterHelper; @@ -66,7 +67,7 @@ protected function getSelectColumnsSQL(): string // Foreign key columns foreach ($subClass->associationMappings as $assoc) { - if (! $assoc['isOwningSide'] || ! ($assoc['type'] & ClassMetadata::TO_ONE) || isset($assoc['inherited'])) { + if (! $assoc->isToOneOwningSide() || isset($assoc['inherited'])) { continue; } @@ -109,7 +110,7 @@ protected function getSQLTableAlias(string $className, string $assocName = ''): /** * {@inheritdoc} */ - protected function getSelectConditionSQL(array $criteria, array|null $assoc = null): string + protected function getSelectConditionSQL(array $criteria, AssociationMapping|null $assoc = null): string { $conditionSql = parent::getSelectConditionSQL($criteria, $assoc); diff --git a/lib/Doctrine/ORM/Query/AST/Functions/IdentityFunction.php b/lib/Doctrine/ORM/Query/AST/Functions/IdentityFunction.php index 720f5d48fba..7b248d99e20 100644 --- a/lib/Doctrine/ORM/Query/AST/Functions/IdentityFunction.php +++ b/lib/Doctrine/ORM/Query/AST/Functions/IdentityFunction.php @@ -36,7 +36,9 @@ public function getSql(SqlWalker $sqlWalker): string $assocField = $this->pathExpression->field; $assoc = $sqlWalker->getMetadataForDqlAlias($dqlAlias)->associationMappings[$assocField]; $targetEntity = $entityManager->getClassMetadata($assoc['targetEntity']); - $joinColumn = reset($assoc['joinColumns']); + + assert($assoc->isToOneOwningSide()); + $joinColumn = reset($assoc->joinColumns); if ($this->fieldMapping !== null) { if (! isset($targetEntity->fieldMappings[$this->fieldMapping])) { diff --git a/lib/Doctrine/ORM/Query/AST/Functions/SizeFunction.php b/lib/Doctrine/ORM/Query/AST/Functions/SizeFunction.php index f95eeefe2f0..924110e2fdd 100644 --- a/lib/Doctrine/ORM/Query/AST/Functions/SizeFunction.php +++ b/lib/Doctrine/ORM/Query/AST/Functions/SizeFunction.php @@ -64,7 +64,7 @@ public function getSql(SqlWalker $sqlWalker): string } else { // many-to-many $targetClass = $entityManager->getClassMetadata($assoc['targetEntity']); - $owningAssoc = $assoc['isOwningSide'] ? $assoc : $targetClass->associationMappings[$assoc['mappedBy']]; + $owningAssoc = $assoc->isOwningSide() ? $assoc : $targetClass->associationMappings[$assoc['mappedBy']]; $joinTable = $owningAssoc['joinTable']; // SQL table aliases @@ -74,7 +74,7 @@ public function getSql(SqlWalker $sqlWalker): string // join to target table $sql .= $quoteStrategy->getJoinTableName($owningAssoc, $targetClass, $platform) . ' ' . $joinTableAlias . ' WHERE '; - $joinColumns = $assoc['isOwningSide'] + $joinColumns = $assoc->isOwningSide() ? $joinTable['joinColumns'] : $joinTable['inverseJoinColumns']; diff --git a/lib/Doctrine/ORM/Query/Parser.php b/lib/Doctrine/ORM/Query/Parser.php index cca633eee41..dee03971a66 100644 --- a/lib/Doctrine/ORM/Query/Parser.php +++ b/lib/Doctrine/ORM/Query/Parser.php @@ -7,6 +7,7 @@ use Doctrine\Common\Lexer\Token; use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; use Doctrine\ORM\Query\AST\Functions; @@ -34,7 +35,6 @@ * An LL(*) recursive-descent parser for the context-free grammar of the Doctrine Query Language. * Parses a DQL query, reports any errors in it, and generates an AST. * - * @psalm-import-type AssociationMapping from ClassMetadata * @psalm-type DqlToken = Token * @psalm-type QueryComponent = array{ * metadata?: ClassMetadata, @@ -625,8 +625,7 @@ private function processDeferredPartialObjectExpressions(): void if ( isset($class->associationMappings[$field]) && - $class->associationMappings[$field]['isOwningSide'] && - $class->associationMappings[$field]['type'] & ClassMetadata::TO_ONE + $class->associationMappings[$field]->isToOneOwningSide() ) { continue; } @@ -718,7 +717,7 @@ private function processDeferredPathExpressions(): void if (isset($class->associationMappings[$field])) { $assoc = $class->associationMappings[$field]; - $fieldType = $assoc['type'] & ClassMetadata::TO_ONE + $fieldType = $assoc->isToOne() ? AST\PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION : AST\PathExpression::TYPE_COLLECTION_VALUED_ASSOCIATION; } diff --git a/lib/Doctrine/ORM/Query/QueryException.php b/lib/Doctrine/ORM/Query/QueryException.php index f452f6f28a4..6a78783b906 100644 --- a/lib/Doctrine/ORM/Query/QueryException.php +++ b/lib/Doctrine/ORM/Query/QueryException.php @@ -5,13 +5,12 @@ namespace Doctrine\ORM\Query; use Doctrine\ORM\Exception\ORMException; -use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Query\AST\PathExpression; use Exception; use Stringable; use Throwable; -/** @psalm-import-type AssociationMapping from ClassMetadata */ class QueryException extends Exception implements ORMException { public static function dqlError(string $dql): self @@ -81,11 +80,7 @@ public static function invalidLiteral(string|Stringable $literal): self return new self("Invalid literal '" . $literal . "'"); } - /** - * @param string[] $assoc - * @psalm-param AssociationMapping $assoc - */ - public static function iterateWithFetchJoinCollectionNotAllowed(array $assoc): self + public static function iterateWithFetchJoinCollectionNotAllowed(AssociationMapping $assoc): self { return new self( 'Invalid query operation: Not allowed to iterate over fetch join collections ' . @@ -123,11 +118,7 @@ public static function associationPathInverseSideNotSupported(PathExpression $pa ); } - /** - * @param string[] $assoc - * @psalm-param AssociationMapping $assoc - */ - public static function iterateWithFetchJoinNotAllowed(array $assoc): self + public static function iterateWithFetchJoinNotAllowed(AssociationMapping $assoc): self { return new self( 'Iterate with fetch join in class ' . $assoc['sourceEntity'] . diff --git a/lib/Doctrine/ORM/Query/ResultSetMappingBuilder.php b/lib/Doctrine/ORM/Query/ResultSetMappingBuilder.php index 073b8ec8639..92a99ed1624 100644 --- a/lib/Doctrine/ORM/Query/ResultSetMappingBuilder.php +++ b/lib/Doctrine/ORM/Query/ResultSetMappingBuilder.php @@ -136,7 +136,7 @@ protected function addAllClassFields(string $class, string $alias, array $column } foreach ($classMetadata->associationMappings as $associationMapping) { - if ($associationMapping['isOwningSide'] && $associationMapping['type'] & ClassMetadata::TO_ONE) { + if ($associationMapping->isToOneOwningSide()) { $targetClass = $this->em->getClassMetadata($associationMapping['targetEntity']); $isIdentifier = isset($associationMapping['id']) && $associationMapping['id'] === true; @@ -216,7 +216,7 @@ private function getColumnAliasMap( } foreach ($class->associationMappings as $associationMapping) { - if ($associationMapping['isOwningSide'] && $associationMapping['type'] & ClassMetadata::TO_ONE) { + if ($associationMapping->isToOneOwningSide()) { foreach ($associationMapping['joinColumns'] as $joinColumn) { $columnName = $joinColumn['name']; $columnAlias[$columnName] = $this->getColumnAlias($columnName, $mode, $customRenameColumns); diff --git a/lib/Doctrine/ORM/Query/SqlWalker.php b/lib/Doctrine/ORM/Query/SqlWalker.php index bc72b7fd7b4..5ba0cbacf83 100644 --- a/lib/Doctrine/ORM/Query/SqlWalker.php +++ b/lib/Doctrine/ORM/Query/SqlWalker.php @@ -597,8 +597,9 @@ public function walkPathExpression(AST\PathExpression $pathExpr): string } $assoc = $class->associationMappings[$fieldName]; + assert($assoc->isToOne()); - if (! $assoc['isOwningSide']) { + if (! $assoc->isOwningSide()) { throw QueryException::associationPathInverseSideNotSupported($pathExpr); } @@ -611,7 +612,8 @@ public function walkPathExpression(AST\PathExpression $pathExpr): string $sql .= $this->getSQLTableAlias($class->getTableName(), $dqlAlias) . '.'; } - $sql .= reset($assoc['targetToSourceKeyColumns']); + assert($assoc->targetToSourceKeyColumns !== null); + $sql .= reset($assoc->targetToSourceKeyColumns); break; default: @@ -680,7 +682,7 @@ public function walkSelectClause(AST\SelectClause $selectClause): string // Add foreign key columns of class and also parent classes foreach ($class->associationMappings as $assoc) { if ( - ! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE) + ! $assoc->isToOneOwningSide() || ( ! $addMetaColumns && ! isset($assoc['id'])) ) { continue; @@ -719,7 +721,7 @@ public function walkSelectClause(AST\SelectClause $selectClause): string continue; } - if ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE) { + if ($assoc->isToOneOwningSide()) { $targetClass = $this->em->getClassMetadata($assoc['targetEntity']); foreach ($assoc['joinColumns'] as $joinColumn) { @@ -797,8 +799,9 @@ public function walkIndexBy(AST\IndexBy $indexBy): void } $association = $class->associationMappings[$fieldName]; + assert($association->isToOne()); - if (! $association['isOwningSide']) { + if (! $association->isOwningSide()) { throw QueryException::associationPathInverseSideNotSupported($pathExpression); } @@ -806,7 +809,8 @@ public function walkIndexBy(AST\IndexBy $indexBy): void throw QueryException::associationPathCompositeKeyNotSupported(); } - $field = reset($association['targetToSourceKeyColumns']); + assert($association->targetToSourceKeyColumns !== null); + $field = reset($association->targetToSourceKeyColumns); break; default: @@ -905,7 +909,7 @@ public function walkJoinAssociationDeclaration( // be the owning side and previously we ensured that $assoc is always the owning side of the associations. // The owning side is necessary at this point because only it contains the JoinColumn information. switch (true) { - case $assoc['type'] & ClassMetadata::TO_ONE: + case $assoc->isToOne(): $conditions = []; foreach ($assoc['joinColumns'] as $joinColumn) { @@ -1639,7 +1643,7 @@ public function walkGroupByItem(AST\PathExpression|string $groupByItem): string } foreach ($this->getMetadataForDqlAlias($groupByItem)->associationMappings as $mapping) { - if ($mapping['isOwningSide'] && $mapping['type'] & ClassMetadata::TO_ONE) { + if ($mapping->isToOneOwningSide()) { $item = new AST\PathExpression(AST\PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION, $groupByItem, $mapping['fieldName']); $item->type = AST\PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION; diff --git a/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php b/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php index 25ec627476e..41a177d9186 100644 --- a/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php +++ b/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php @@ -5,6 +5,7 @@ namespace Doctrine\ORM\Tools\Console\Command; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\Persistence\Mapping\MappingException; @@ -41,8 +42,6 @@ * Show information about mapped entities. * * @link www.doctrine-project.org - * - * @psalm-import-type AssociationMapping from ClassMetadata */ final class MappingDescribeCommand extends AbstractEntityManagerCommand { diff --git a/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php b/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php index 52bd953aaf6..8219620ef19 100644 --- a/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php +++ b/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php @@ -6,7 +6,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\OnFlushEventArgs; -use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\UnitOfWork; use Doctrine\Persistence\Proxy; @@ -71,7 +70,7 @@ public function dumpIdentityMap(EntityManagerInterface $em): void fwrite($fh, ' ' . $field . ' '); $value = $cm->getFieldValue($entity, $field); - if ($assoc['type'] & ClassMetadata::TO_ONE) { + if ($assoc->isToOne()) { if ($value === null) { fwrite($fh, " NULL\n"); } else { diff --git a/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php b/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php index 52e3c2841d0..0eaad9f17c5 100644 --- a/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php +++ b/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php @@ -5,7 +5,6 @@ namespace Doctrine\ORM\Tools\Pagination; use Doctrine\DBAL\Types\Type; -use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; use Doctrine\ORM\Query\AST\Functions\IdentityFunction; use Doctrine\ORM\Query\AST\Node; @@ -125,7 +124,7 @@ private function validate(SelectStatement $AST): void if ( isset($queryComponent['parent']) && isset($queryComponent['relation']) - && $queryComponent['relation']['type'] & ClassMetadata::TO_MANY + && $queryComponent['relation']->isToMany() ) { throw new RuntimeException('Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers.'); } diff --git a/lib/Doctrine/ORM/Tools/ResolveTargetEntityListener.php b/lib/Doctrine/ORM/Tools/ResolveTargetEntityListener.php index 9fa30f8d957..0e4a44adc20 100644 --- a/lib/Doctrine/ORM/Tools/ResolveTargetEntityListener.php +++ b/lib/Doctrine/ORM/Tools/ResolveTargetEntityListener.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Event\LoadClassMetadataEventArgs; use Doctrine\ORM\Event\OnClassMetadataNotFoundEventArgs; use Doctrine\ORM\Events; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use function array_key_exists; @@ -19,8 +20,6 @@ * * Mechanism to overwrite interfaces or classes specified as association * targets. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ class ResolveTargetEntityListener implements EventSubscriber { @@ -89,11 +88,13 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $args): void } } - /** @param AssociationMapping $mapping */ - private function remapAssociation(ClassMetadata $classMetadata, array $mapping): void + private function remapAssociation(ClassMetadata $classMetadata, AssociationMapping $mapping): void { $newMapping = $this->resolveTargetEntities[$mapping['targetEntity']]; - $newMapping = array_replace_recursive($mapping, $newMapping); + $newMapping = array_replace_recursive( + $mapping->toArray(), + $newMapping, + ); $newMapping['fieldName'] = $mapping['fieldName']; unset($classMetadata->associationMappings[$mapping['fieldName']]); diff --git a/lib/Doctrine/ORM/Tools/SchemaTool.php b/lib/Doctrine/ORM/Tools/SchemaTool.php index a2c7486f330..467d8494d64 100644 --- a/lib/Doctrine/ORM/Tools/SchemaTool.php +++ b/lib/Doctrine/ORM/Tools/SchemaTool.php @@ -14,9 +14,12 @@ use Doctrine\DBAL\Schema\Visitor\RemoveNamespacedAssets; use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\DiscriminatorColumnMapping; use Doctrine\ORM\Mapping\FieldMapping; +use Doctrine\ORM\Mapping\JoinColumnMapping; +use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\QuoteStrategy; use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; @@ -37,7 +40,6 @@ use function func_num_args; use function implode; use function in_array; -use function is_array; use function is_numeric; use function method_exists; use function strtolower; @@ -47,9 +49,6 @@ * ClassMetadata class descriptors. * * @link www.doctrine-project.org - * - * @psalm-import-type AssociationMapping from ClassMetadata - * @psalm-import-type JoinColumnData from ClassMetadata */ class SchemaTool { @@ -300,7 +299,6 @@ public function getSchemaFromMetadata(array $classes): Schema $pkColumns[] = $this->quoteStrategy->getColumnName($identifierField, $class, $this->platform); } elseif (isset($class->associationMappings[$identifierField])) { $assoc = $class->associationMappings[$identifierField]; - assert(is_array($assoc)); foreach ($assoc['joinColumns'] as $joinColumn) { $pkColumns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); @@ -544,7 +542,7 @@ private function gatherRelationsSql( $foreignClass = $this->em->getClassMetadata($mapping['targetEntity']); - if ($mapping['type'] & ClassMetadata::TO_ONE && $mapping['isOwningSide']) { + if ($mapping->isToOneOwningSide()) { $primaryKeyColumns = []; // PK is unnecessary for this relation-type $this->gatherRelationJoinColumns( @@ -556,10 +554,7 @@ private function gatherRelationsSql( $addedFks, $blacklistedFks, ); - } elseif ($mapping['type'] === ClassMetadata::ONE_TO_MANY && $mapping['isOwningSide']) { - //... create join table, one-many through join table supported later - throw NotSupported::create(); - } elseif ($mapping['type'] === ClassMetadata::MANY_TO_MANY && $mapping['isOwningSide']) { + } elseif ($mapping instanceof ManyToManyOwningSideMapping) { // create join table $joinTable = $mapping['joinTable']; @@ -642,8 +637,7 @@ private function getDefiningClass(ClassMetadata $class, string $referencedColumn /** * Gathers columns and fk constraints that are required for one part of relationship. * - * @psalm-param array $joinColumns - * @psalm-param AssociationMapping $mapping + * @psalm-param array $joinColumns * @psalm-param list $primaryKeyColumns * @psalm-param arrayisOwningSide()) { if ($assoc['type'] === ClassMetadata::MANY_TO_MANY) { $identifierColumns = $class->getIdentifierColumnNames(); foreach ($assoc['joinTable']['joinColumns'] as $joinColumn) { @@ -194,7 +194,7 @@ public function validateClass(ClassMetadata $class): array "however '" . implode(', ', array_diff($class->getIdentifierColumnNames(), array_values($assoc['relationToSourceKeyColumns']))) . "' are missing."; } - } elseif ($assoc['type'] & ClassMetadata::TO_ONE) { + } elseif ($assoc->isToOne()) { $identifierColumns = $targetMetadata->getIdentifierColumnNames(); foreach ($assoc['joinColumns'] as $joinColumn) { if (! in_array($joinColumn['referencedColumnName'], $identifierColumns, true)) { diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index a805666f9e8..f72091739de 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -30,6 +30,7 @@ use Doctrine\ORM\Id\AssignedGenerator; use Doctrine\ORM\Internal\CommitOrderCalculator; use Doctrine\ORM\Internal\HydrationCompleteHandler; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Persisters\Collection\CollectionPersister; @@ -76,8 +77,6 @@ * in the correct order. * * Internal note: This class contains highly performance-sensitive code. - * - * @psalm-import-type AssociationMapping from ClassMetadata */ class UnitOfWork implements PropertyChangedListener { @@ -613,7 +612,7 @@ public function computeChangeSet(ClassMetadata $class, object $entity): void $assoc = $class->associationMappings[$propName]; - if ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE) { + if ($assoc->isToOneOwningSide()) { $changeSet[$propName] = [null, $actualValue]; } } @@ -700,7 +699,7 @@ public function computeChangeSet(ClassMetadata $class, object $entity): void continue; } - if ($assoc['type'] & ClassMetadata::TO_ONE) { + if ($assoc->isToOne()) { if ($assoc['isOwningSide']) { $changeSet[$propName] = [$orgValue, $actualValue]; } @@ -730,8 +729,7 @@ public function computeChangeSet(ClassMetadata $class, object $entity): void if ( ! isset($this->entityChangeSets[$oid]) && - $assoc['isOwningSide'] && - $assoc['type'] === ClassMetadata::MANY_TO_MANY && + $assoc->isManyToManyOwningSide() && $val instanceof PersistentCollection && $val->isDirty() ) { @@ -787,12 +785,11 @@ public function computeChangeSets(): void * Computes the changes of an association. * * @param mixed $value The value of the association. - * @psalm-param AssociationMapping $assoc The association mapping. * * @throws ORMInvalidArgumentException * @throws ORMException */ - private function computeAssociationChanges(array $assoc, mixed $value): void + private function computeAssociationChanges(AssociationMapping $assoc, mixed $value): void { if ($value instanceof Proxy && ! $value->__isInitialized()) { return; @@ -808,7 +805,7 @@ private function computeAssociationChanges(array $assoc, mixed $value): void // Look through the entities, and in any of their associations, // for transient (new) entities, recursively. ("Persistence by reachability") // Unwrap. Uninitialized collections will simply be empty. - $unwrappedValue = $assoc['type'] & ClassMetadata::TO_ONE ? [$value] : $value->unwrap(); + $unwrappedValue = $assoc->isToOne() ? [$value] : $value->unwrap(); $targetClass = $this->em->getClassMetadata($assoc['targetEntity']); foreach ($unwrappedValue as $key => $entry) { @@ -849,7 +846,7 @@ private function computeAssociationChanges(array $assoc, mixed $value): void case self::STATE_REMOVED: // Consume the $value as array (it's either an array or an ArrayAccess) // and remove the element from Collection. - if ($assoc['type'] & ClassMetadata::TO_MANY) { + if ($assoc->isToMany()) { unset($value[$key]); } @@ -1186,7 +1183,7 @@ private function getCommitOrder(): array // Calculate dependencies for new nodes while ($class = array_pop($newNodes)) { foreach ($class->associationMappings as $assoc) { - if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) { + if (! $assoc->isToOneOwningSide()) { continue; } @@ -1198,7 +1195,7 @@ private function getCommitOrder(): array $newNodes[] = $targetClass; } - $joinColumns = reset($assoc['joinColumns']); + $joinColumns = reset($assoc->joinColumns); $calc->addDependency($targetClass->name, $class->name, (int) empty($joinColumns['nullable'])); @@ -1856,7 +1853,7 @@ private function cascadeRefresh(object $entity, array &$visited, LockMode|int|nu $associationMappings = array_filter( $class->associationMappings, - static fn (array $assoc) => $assoc['isCascadeRefresh'] + static fn (AssociationMapping $assoc): bool => $assoc['isCascadeRefresh'] ); foreach ($associationMappings as $assoc) { @@ -1897,7 +1894,7 @@ private function cascadeDetach(object $entity, array &$visited): void $associationMappings = array_filter( $class->associationMappings, - static fn (array $assoc) => $assoc['isCascadeDetach'] + static fn (AssociationMapping $assoc): bool => $assoc['isCascadeDetach'] ); foreach ($associationMappings as $assoc) { @@ -1943,7 +1940,7 @@ private function cascadePersist(object $entity, array &$visited): void $associationMappings = array_filter( $class->associationMappings, - static fn (array $assoc) => $assoc['isCascadePersist'] + static fn (AssociationMapping $assoc): bool => $assoc['isCascadePersist'] ); foreach ($associationMappings as $assoc) { @@ -1957,7 +1954,7 @@ private function cascadePersist(object $entity, array &$visited): void case $relatedEntities instanceof Collection: case is_array($relatedEntities): - if (($assoc['type'] & ClassMetadata::TO_MANY) <= 0) { + if ($assoc->isToMany() <= 0) { throw ORMInvalidArgumentException::invalidAssociation( $this->em->getClassMetadata($assoc['targetEntity']), $assoc, @@ -2000,7 +1997,7 @@ private function cascadeRemove(object $entity, array &$visited): void $associationMappings = array_filter( $class->associationMappings, - static fn (array $assoc) => $assoc['isCascadeRemove'] + static fn (AssociationMapping $assoc): bool => $assoc['isCascadeRemove'] ); $entitiesToCascade = []; @@ -2285,7 +2282,7 @@ public function createEntity(string $className, array $data, array &$hints = []) $targetClass = $this->em->getClassMetadata($assoc['targetEntity']); switch (true) { - case $assoc['type'] & ClassMetadata::TO_ONE: + case $assoc->isToOne(): if (! $assoc['isOwningSide']) { // use the given entity association if (isset($data[$field]) && is_object($data[$field]) && isset($this->entityStates[spl_object_id($data[$field])])) { @@ -2428,7 +2425,7 @@ public function createEntity(string $className, array $data, array &$hints = []) $this->originalEntityData[$oid][$field] = $newValue; $class->reflFields[$field]->setValue($entity, $newValue); - if ($assoc['inversedBy'] && $assoc['type'] & ClassMetadata::ONE_TO_ONE && $newValue !== null) { + if ($assoc['inversedBy'] && $assoc->isOneToOne() && $newValue !== null) { $inverseAssoc = $targetClass->associationMappings[$assoc['inversedBy']]; $targetClass->reflFields[$inverseAssoc['fieldName']]->setValue($newValue, $entity); } @@ -2680,12 +2677,8 @@ public function getEntityPersister(string $entityName): EntityPersister return $this->persisters[$entityName]; } - /** - * Gets a collection persister for a collection-valued association. - * - * @psalm-param AssociationMapping $association - */ - public function getCollectionPersister(array $association): CollectionPersister + /** Gets a collection persister for a collection-valued association. */ + public function getCollectionPersister(AssociationMapping $association): CollectionPersister { $role = isset($association['cache']) ? $association['sourceEntity'] . '::' . $association['fieldName'] diff --git a/lib/Doctrine/ORM/Utility/PersisterHelper.php b/lib/Doctrine/ORM/Utility/PersisterHelper.php index eff5fd2daea..0ea927877b6 100644 --- a/lib/Doctrine/ORM/Utility/PersisterHelper.php +++ b/lib/Doctrine/ORM/Utility/PersisterHelper.php @@ -36,11 +36,11 @@ public static function getTypeOfField(string $fieldName, ClassMetadata $class, E $assoc = $class->associationMappings[$fieldName]; - if (! $assoc['isOwningSide']) { + if (! $assoc->isOwningSide()) { return self::getTypeOfField($assoc['mappedBy'], $em->getClassMetadata($assoc['targetEntity']), $em); } - if ($assoc['type'] & ClassMetadata::MANY_TO_MANY) { + if ($assoc->isManyToMany()) { $joinData = $assoc['joinTable']; } else { $joinData = $assoc; @@ -69,7 +69,7 @@ public static function getTypeOfColumn(string $columnName, ClassMetadata $class, // iterate over to-one association mappings foreach ($class->associationMappings as $assoc) { - if (! isset($assoc['joinColumns'])) { + if (! $assoc->isToOne()) { continue; } @@ -85,7 +85,7 @@ public static function getTypeOfColumn(string $columnName, ClassMetadata $class, // iterate over to-many association mappings foreach ($class->associationMappings as $assoc) { - if (! (isset($assoc['joinTable']) && isset($assoc['joinTable']['joinColumns']))) { + if (! $assoc->isToMany()) { continue; } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7e64ca75bb1..c65e2ab916e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -130,11 +130,6 @@ parameters: count: 1 path: lib/Doctrine/ORM/Mapping/ClassMetadata.php - - - message: "#^Negated boolean expression is always false\\.$#" - count: 1 - path: lib/Doctrine/ORM/Mapping/ClassMetadata.php - - message: "#^If condition is always true\\.$#" count: 2 diff --git a/phpstan.neon b/phpstan.neon index fd318f3530a..836e6c126bf 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -19,3 +19,24 @@ parameters: - message: '~^Method Doctrine\\ORM\\Persisters\\Entity\\BasicEntityPersister\:\:getArrayBindingType\(\) never returns .* so it can be removed from the return type\.$~' path: lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php + + - + message: '~^Unreachable statement \- code above always terminates\.$~' + path: lib/Doctrine/ORM/Mapping/AssociationMapping.php + + # https://github.com/phpstan/phpstan/issues/8904 + - + message: "#^Access to an undefined property Doctrine\\\\ORM\\\\Mapping\\\\ToOneAssociationMapping\\:\\:\\$joinColumns\\.$#" + path: lib/Doctrine/ORM/Mapping/ToOneAssociationMapping.php + + - + message: "#^Method Doctrine\\\\ORM\\\\Mapping\\\\ToOneAssociationMapping\\:\\:fromMappingArrayAndName\\(\\) should return Doctrine\\\\ORM\\\\Mapping\\\\ManyToOneAssociationMapping\\|Doctrine\\\\ORM\\\\Mapping\\\\OneToOneAssociationMapping but returns static\\(Doctrine\\\\ORM\\\\Mapping\\\\ToOneAssociationMapping\\)\\.$#" + path: lib/Doctrine/ORM/Mapping/ToOneAssociationMapping.php + + - + message: "#^Property Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata\\\\:\\:\\$associationMappings \\(array\\\\) does not accept array\\\\.$#" + path: lib/Doctrine/ORM/Mapping/ClassMetadata.php + + - + message: "#^Property Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata\\\\:\\:\\$associationMappings \\(array\\\\) does not accept array\\\\.$#" + path: lib/Doctrine/ORM/Mapping/ClassMetadata.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 275683eca87..091daee6fcd 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -26,9 +26,6 @@ - - - getConfiguration() ->getSecondLevelCacheConfiguration() @@ -39,14 +36,6 @@ - - associationMappings]]> - - - - - - getCacheRegion @@ -236,10 +225,6 @@ - - associationMappings[$fieldName]['joinColumns']]]> - associationMappings[$fieldName]['joinColumns']]]> - return $rowData; return $rowData; @@ -283,12 +268,6 @@ $parentObject $parentObject - - - - - reflFields]]> - getValue getValue @@ -298,16 +277,9 @@ setValue - associationMappings[$class->identifier[0]]['joinColumns']]]> - associationMappings[$fieldName]['joinColumns']]]> - - - - - $repositoryClassName @@ -325,9 +297,6 @@ columnNames]]> columnNames]]> - - - $mapping $mapping @@ -336,18 +305,11 @@ ReflectionClass|null - string $definition subClasses]]> - - $mapping - - - AssociationMapping - $cache $className @@ -361,7 +323,6 @@ ]]> - associationMappings[$assocName]['mappedBy']]]> reflClass]]> @@ -387,12 +348,9 @@ setValue + - associationMappings[$assocName]['joinColumns']]]> - associationMappings[$fieldName]['joinColumns']]]> - associationMappings[$fieldName]['joinColumns']]]> - associationMappings[$idProperty]['joinColumns']]]> $idGenerator @@ -406,11 +364,18 @@ array_values - - - table]]> + table]]> - + null + + + namingStrategy, + $this->name, + $this->table, + $this->isInheritanceTypeSingleTable(), + )]]> @@ -462,10 +427,6 @@ array - - - associationMappings[$fieldName]['joinColumns']]]> - @@ -630,6 +591,25 @@ getName() === 'mapped-superclass']]> + + + $mappingArray + + + $joinTable + + + joinTable]]> + + + new JoinTableMapping() + + + + + $mappingArray + + $object @@ -654,6 +634,16 @@ ReflectionReadonlyProperty + + + $instance + $mapping + + + OneToOneAssociationMapping|ManyToOneAssociationMapping + static + + @@ -684,48 +674,18 @@ $value - association]]> - association]]> - association]]> - association['targetEntity']]]> backRefFieldName]]> - - association['fetch']]]> - association['fetch']]]> - association['fetch']]]> - association['fetch']]]> - association['fetch']]]> - association['isOwningSide']]]> - association['orphanRemoval']]]> - association['targetEntity']]]> - association['type']]]> - association['type']]]> - association['type']]]> - association['type']]]> - setValue setValue - - association['orphanRemoval']]]> - association['orphanRemoval']]]> - association['orphanRemoval']]]> - unwrap(), 'add']]]> - - $mapping - - - $collection->getOwner(), $mapping['indexBy'] => $index]]]> - - $association getOwner()]]> getOwner()]]> getOwner()]]> @@ -733,160 +693,14 @@ getOwner()]]> getOwner()]]> getOwner()]]> - $column - $filterMapping - $filterMapping - $indexBy - $mapping - $mapping - $mapping - $mapping - $mapping - $mapping - $mapping - $mapping - $mapping - - - - - - - - - - - - - - - - - - - - - - $owner - $targetColumn - - - - - - $mapping[$sourceRelationMode] - $mapping[$targetRelationMode] - $mapping[$targetRelationMode][$joinTableColumn] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - associationMappings]]> - associationMappings]]> - associationMappings]]> - associationMappings]]> - associationMappings]]> - - - $joinColumns - $joinColumns - $mapping[$sourceRelationMode] - - - - - - - - - - - getFieldForColumn getFieldForColumn - - - - - - $mapping[$sourceRelationMode] - $mapping[$targetRelationMode] - - - - - - - - - - - - - - - - - - $collection->getOwner(), - $mapping['indexBy'] => $index, - ]]]> - $numDeleted conn->executeStatement($statement, $parameters)]]> @@ -899,51 +713,13 @@ getOwner()]]> getOwner()]]> getOwner()]]> - $mapping - - - - - - - - - - - - - - - - - - - - - - - - - - - - associationMappings]]> - - - - associationMappings[$mapping['mappedBy']]['joinColumns']]]> - - - $association - $value === null - $assoc $hints $hints true]]]> @@ -963,27 +739,8 @@ ]]> - - - $association - - - - - - - - - associationMappings]]> - associationMappings]]> - associationMappings]]> - class->associationMappings]]> - - - $joinColumns - getValue getValue @@ -993,28 +750,6 @@ getValue setValue - - - - - - - - - - - - - - - - - - - - class->associationMappings[$fieldName]['joinColumns']]]> - class->associationMappings[$idField]['joinColumns']]]> - currentPersisterContext->sqlTableAliases]]> @@ -1031,16 +766,6 @@ array - - - - - - - - - - @@ -1127,26 +852,11 @@ $sqlWalker - - - - - simpleArithmeticExpression]]> - - - associationMappings]]> - associationMappings]]> - - - - - - walkJoinPathExpression @@ -1341,12 +1051,6 @@ Comparison::EQ - - - - - - conn->quote((string) $newValue)]]> @@ -1367,27 +1071,12 @@ elseScalarExpression]]> - associationMappings]]> - associationMappings]]> - associationMappings]]> scalarResultAliasMap]]> scalarResultAliasMap]]> dispatch - - - - - - - - - - - - @@ -1517,11 +1206,6 @@ $state === UnitOfWork::STATE_DETACHED - - - associationMappings[$property]['joinColumns']]]> - - @@ -1535,9 +1219,6 @@ orderByItems]]> - - associationMappings[$property]['joinColumns']]]> - @@ -1568,46 +1249,19 @@ $classes - - - - $asset $referencedFieldName - - - getAssociationMapping($fieldName)['joinColumns']]]> - - - - is_numeric($indexName) - - assert(is_array($assoc)) - is_array($assoc) - $indexName - - - - - - - - - - - - $collectionToDelete @@ -1628,23 +1282,11 @@ identityMap[$rootClassName]]]> - $assoc - $assoc - getTypeOfField($class->getSingleIdentifierFieldName())]]> getOwner()]]> getOwner()]]> - getMapping()]]> - getMapping()]]> $owner - - - - - - reflFields]]> - buildCachedCollectionPersister buildCachedEntityPersister @@ -1672,11 +1314,6 @@ setValue setValue - - - - - unwrap unwrap @@ -1696,17 +1333,4 @@ subClasses]]> - - - associationMappings[$field]['joinColumns']]]> - - - - - - - - - - diff --git a/tests/Doctrine/Performance/Mock/NonLoadingPersister.php b/tests/Doctrine/Performance/Mock/NonLoadingPersister.php index 8fb8b06dffa..7058092bf68 100644 --- a/tests/Doctrine/Performance/Mock/NonLoadingPersister.php +++ b/tests/Doctrine/Performance/Mock/NonLoadingPersister.php @@ -5,6 +5,7 @@ namespace Doctrine\Performance\Mock; use Doctrine\DBAL\LockMode; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Persisters\Entity\BasicEntityPersister; /** @@ -22,7 +23,7 @@ public function __construct() public function load( array $criteria, object|null $entity = null, - array|null $assoc = null, + AssociationMapping|null $assoc = null, array $hints = [], LockMode|int|null $lockMode = null, int|null $limit = null, diff --git a/tests/Doctrine/Tests/Models/DDC964/DDC964Admin.php b/tests/Doctrine/Tests/Models/DDC964/DDC964Admin.php index 1d802dea597..3668828f052 100644 --- a/tests/Doctrine/Tests/Models/DDC964/DDC964Admin.php +++ b/tests/Doctrine/Tests/Models/DDC964/DDC964Admin.php @@ -35,10 +35,10 @@ public static function loadMetadata(ClassMetadata $metadata): void 'joinTable' => [ 'name' => 'ddc964_users_admingroups', 'joinColumns' => [ - ['name' => 'adminuser_id'], + ['name' => 'adminuser_id', 'referencedColumnName' => 'id'], ], 'inverseJoinColumns' => [ - ['name' => 'admingroup_id'], + ['name' => 'admingroup_id', 'referencedColumnName' => 'id'], ], ], ], diff --git a/tests/Doctrine/Tests/Models/DDC964/DDC964User.php b/tests/Doctrine/Tests/Models/DDC964/DDC964User.php index d976a26be28..f2f17c14b97 100644 --- a/tests/Doctrine/Tests/Models/DDC964/DDC964User.php +++ b/tests/Doctrine/Tests/Models/DDC964/DDC964User.php @@ -111,7 +111,7 @@ public static function loadMetadata(ClassMetadata $metadata): void 'fieldName' => 'address', 'targetEntity' => 'DDC964Address', 'cascade' => ['persist','merge'], - 'joinColumns' => [['name' => 'address_id', 'referencedColumnMame' => 'id']], + 'joinColumns' => [['name' => 'address_id', 'referencedColumnName' => 'id']], ], ); diff --git a/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php b/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php index 3e52945b1e6..22ac2127ac2 100644 --- a/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php +++ b/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php @@ -109,7 +109,7 @@ public static function loadMetadata(ClassMetadata $metadata): void 'joinColumns' => [ 0 => - [], + ['referencedColumnName' => 'id'], ], 'orphanRemoval' => true, ], diff --git a/tests/Doctrine/Tests/ORM/Cache/DefaultCacheFactoryTest.php b/tests/Doctrine/Tests/ORM/Cache/DefaultCacheFactoryTest.php index 6dd074527ae..2ceba5a411c 100644 --- a/tests/Doctrine/Tests/ORM/Cache/DefaultCacheFactoryTest.php +++ b/tests/Doctrine/Tests/ORM/Cache/DefaultCacheFactoryTest.php @@ -127,7 +127,7 @@ public function testBuildCachedCollectionPersisterReadOnly(): void $persister = new OneToManyPersister($em); $region = new ConcurrentRegionMock(new DefaultRegion('regionName', $this->getSharedSecondLevelCache())); - $mapping['cache']['usage'] = ClassMetadata::CACHE_USAGE_READ_ONLY; + $mapping->cache['usage'] = ClassMetadata::CACHE_USAGE_READ_ONLY; $this->factory->expects(self::once()) ->method('getRegion') @@ -148,7 +148,7 @@ public function testBuildCachedCollectionPersisterReadWrite(): void $persister = new OneToManyPersister($em); $region = new ConcurrentRegionMock(new DefaultRegion('regionName', $this->getSharedSecondLevelCache())); - $mapping['cache']['usage'] = ClassMetadata::CACHE_USAGE_READ_WRITE; + $mapping->cache['usage'] = ClassMetadata::CACHE_USAGE_READ_WRITE; $this->factory->expects(self::once()) ->method('getRegion') @@ -169,7 +169,7 @@ public function testBuildCachedCollectionPersisterNonStrictReadWrite(): void $persister = new OneToManyPersister($em); $region = new ConcurrentRegionMock(new DefaultRegion('regionName', $this->getSharedSecondLevelCache())); - $mapping['cache']['usage'] = ClassMetadata::CACHE_USAGE_NONSTRICT_READ_WRITE; + $mapping->cache['usage'] = ClassMetadata::CACHE_USAGE_NONSTRICT_READ_WRITE; $this->factory->expects(self::once()) ->method('getRegion') @@ -241,7 +241,7 @@ public function testBuildCachedCollectionPersisterException(): void $mapping = $metadata->associationMappings['cities']; $persister = new OneToManyPersister($em); - $mapping['cache']['usage'] = -1; + $mapping->cache['usage'] = -1; $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unrecognized access strategy type [-1]'); diff --git a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/CollectionPersisterTestCase.php b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/CollectionPersisterTestCase.php index d05f9268b1b..5318f96dff0 100644 --- a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/CollectionPersisterTestCase.php +++ b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/CollectionPersisterTestCase.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Cache\Persister\Collection\CachedCollectionPersister; use Doctrine\ORM\Cache\Region; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use Doctrine\Tests\Mocks\EntityManagerMock; @@ -25,7 +26,12 @@ abstract class CollectionPersisterTestCase extends OrmTestCase protected CollectionPersister&MockObject $collectionPersister; protected EntityManagerMock $em; - abstract protected function createPersister(EntityManagerInterface $em, CollectionPersister $persister, Region $region, array $mapping): AbstractCollectionPersister; + abstract protected function createPersister( + EntityManagerInterface $em, + CollectionPersister $persister, + Region $region, + AssociationMapping $mapping, + ): AbstractCollectionPersister; protected function setUp(): void { diff --git a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/NonStrictReadWriteCachedCollectionPersisterTest.php b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/NonStrictReadWriteCachedCollectionPersisterTest.php index c4c0d1e16dc..4b79d021000 100644 --- a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/NonStrictReadWriteCachedCollectionPersisterTest.php +++ b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/NonStrictReadWriteCachedCollectionPersisterTest.php @@ -8,14 +8,19 @@ use Doctrine\ORM\Cache\Persister\Collection\NonStrictReadWriteCachedCollectionPersister; use Doctrine\ORM\Cache\Region; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use PHPUnit\Framework\Attributes\Group; #[Group('DDC-2183')] class NonStrictReadWriteCachedCollectionPersisterTest extends CollectionPersisterTestCase { - protected function createPersister(EntityManagerInterface $em, CollectionPersister $persister, Region $region, array $mapping): AbstractCollectionPersister - { + protected function createPersister( + EntityManagerInterface $em, + CollectionPersister $persister, + Region $region, + AssociationMapping $mapping, + ): AbstractCollectionPersister { return new NonStrictReadWriteCachedCollectionPersister($persister, $region, $em, $mapping); } } diff --git a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadOnlyCachedCollectionPersisterTest.php b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadOnlyCachedCollectionPersisterTest.php index 5c55a1d8761..8fb07fa4b3e 100644 --- a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadOnlyCachedCollectionPersisterTest.php +++ b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadOnlyCachedCollectionPersisterTest.php @@ -8,14 +8,19 @@ use Doctrine\ORM\Cache\Persister\Collection\ReadOnlyCachedCollectionPersister; use Doctrine\ORM\Cache\Region; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use PHPUnit\Framework\Attributes\Group; #[Group('DDC-2183')] class ReadOnlyCachedCollectionPersisterTest extends CollectionPersisterTestCase { - protected function createPersister(EntityManagerInterface $em, CollectionPersister $persister, Region $region, array $mapping): AbstractCollectionPersister - { + protected function createPersister( + EntityManagerInterface $em, + CollectionPersister $persister, + Region $region, + AssociationMapping $mapping, + ): AbstractCollectionPersister { return new ReadOnlyCachedCollectionPersister($persister, $region, $em, $mapping); } } diff --git a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersisterTest.php b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersisterTest.php index 107f24ac11c..2b87882fa60 100644 --- a/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersisterTest.php +++ b/tests/Doctrine/Tests/ORM/Cache/Persister/Collection/ReadWriteCachedCollectionPersisterTest.php @@ -11,6 +11,7 @@ use Doctrine\ORM\Cache\Persister\Collection\ReadWriteCachedCollectionPersister; use Doctrine\ORM\Cache\Region; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Persisters\Collection\CollectionPersister; use Doctrine\Tests\Models\Cache\State; use PHPUnit\Framework\Attributes\Group; @@ -20,8 +21,12 @@ #[Group('DDC-2183')] class ReadWriteCachedCollectionPersisterTest extends CollectionPersisterTestCase { - protected function createPersister(EntityManagerInterface $em, CollectionPersister $persister, Region $region, array $mapping): AbstractCollectionPersister - { + protected function createPersister( + EntityManagerInterface $em, + CollectionPersister $persister, + Region $region, + AssociationMapping $mapping, + ): AbstractCollectionPersister { return new ReadWriteCachedCollectionPersister($persister, $region, $em, $mapping); } diff --git a/tests/Doctrine/Tests/ORM/Cache/Persister/Entity/EntityPersisterTestCase.php b/tests/Doctrine/Tests/ORM/Cache/Persister/Entity/EntityPersisterTestCase.php index c357fe66ea1..ca67a5a8b61 100644 --- a/tests/Doctrine/Tests/ORM/Cache/Persister/Entity/EntityPersisterTestCase.php +++ b/tests/Doctrine/Tests/ORM/Cache/Persister/Entity/EntityPersisterTestCase.php @@ -12,6 +12,7 @@ use Doctrine\ORM\Cache\Region; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\OneToOneAssociationMapping; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Persisters\Entity\EntityPersister; use Doctrine\ORM\Query\ResultSetMappingBuilder; @@ -89,11 +90,13 @@ public function testInvokeGetSelectSQL(): void { $persister = $this->createPersisterDefault(); + $associationMapping = new OneToOneAssociationMapping('foo', 'bar', 'baz'); + $this->entityPersister->expects(self::once()) ->method('getSelectSQL') ->with( self::identicalTo(['name' => 'Foo']), - self::identicalTo([0]), + self::identicalTo($associationMapping), self::identicalTo(1), self::identicalTo(2), self::identicalTo(3), @@ -103,7 +106,7 @@ public function testInvokeGetSelectSQL(): void self::assertSame('SELECT * FROM foo WERE name = ?', $persister->getSelectSQL( ['name' => 'Foo'], - [0], + $associationMapping, 1, 2, 3, @@ -151,12 +154,18 @@ public function testInvokeSelectConditionStatementSQL(): void { $persister = $this->createPersisterDefault(); + $associationMapping = new OneToOneAssociationMapping('foo', 'bar', 'baz'); + $this->entityPersister->expects(self::once()) ->method('getSelectConditionStatementSQL') - ->with(self::identicalTo('id'), self::identicalTo(1), self::identicalTo([]), self::identicalTo('=')) - ->willReturn('name = 1'); + ->with( + self::identicalTo('id'), + self::identicalTo(1), + self::identicalTo($associationMapping), + self::identicalTo('='), + )->willReturn('name = 1'); - self::assertSame('name = 1', $persister->getSelectConditionStatementSQL('id', 1, [], '=')); + self::assertSame('name = 1', $persister->getSelectConditionStatementSQL('id', 1, $associationMapping, '=')); } public function testInvokeExecuteInserts(): void @@ -216,12 +225,14 @@ public function testInvokeLoad(): void $persister = $this->createPersisterDefault(); $entity = new Country('Foo'); + $associationMapping = new OneToOneAssociationMapping('foo', 'bar', 'baz'); + $this->entityPersister->expects(self::once()) ->method('load') ->with( self::identicalTo(['id' => 1]), self::identicalTo($entity), - self::identicalTo([0]), + self::identicalTo($associationMapping), self::identicalTo([1]), self::identicalTo(2), self::identicalTo(3), @@ -229,7 +240,7 @@ public function testInvokeLoad(): void ) ->willReturn($entity); - self::assertSame($entity, $persister->load(['id' => 1], $entity, [0], [1], 2, 3, [4])); + self::assertSame($entity, $persister->load(['id' => 1], $entity, $associationMapping, [1], 2, 3, [4])); } public function testInvokeLoadAll(): void @@ -274,12 +285,14 @@ public function testInvokeLoadOneToOneEntity(): void $entity = new Country('Foo'); $owner = (object) []; + $associationMapping = new OneToOneAssociationMapping('foo', 'bar', 'baz'); + $this->entityPersister->expects(self::once()) ->method('loadOneToOneEntity') - ->with(self::identicalTo([]), self::identicalTo($owner), self::identicalTo(['id' => 11])) + ->with(self::identicalTo($associationMapping), self::identicalTo($owner), self::identicalTo(['id' => 11])) ->willReturn($entity); - self::assertSame($entity, $persister->loadOneToOneEntity([], $owner, ['id' => 11])); + self::assertSame($entity, $persister->loadOneToOneEntity($associationMapping, $owner, ['id' => 11])); } public function testInvokeRefresh(): void @@ -323,12 +336,14 @@ public function testInvokeGetManyToManyCollection(): void $entity = new Country('Foo'); $owner = (object) []; + $associationMapping = new OneToOneAssociationMapping('foo', 'bar', 'baz'); + $this->entityPersister->expects(self::once()) ->method('getManyToManyCollection') - ->with(self::identicalTo([]), self::identicalTo($owner), self::identicalTo(1), self::identicalTo(2)) + ->with(self::identicalTo($associationMapping), self::identicalTo($owner), self::identicalTo(1), self::identicalTo(2)) ->willReturn([$entity]); - self::assertSame([$entity], $persister->getManyToManyCollection([], $owner, 1, 2)); + self::assertSame([$entity], $persister->getManyToManyCollection($associationMapping, $owner, 1, 2)); } public function testInvokeGetOneToManyCollection(): void @@ -337,18 +352,20 @@ public function testInvokeGetOneToManyCollection(): void $entity = new Country('Foo'); $owner = (object) []; + $associationMapping = new OneToOneAssociationMapping('foo', 'bar', 'baz'); + $this->entityPersister->expects(self::once()) ->method('getOneToManyCollection') - ->with(self::identicalTo([]), self::identicalTo($owner), self::identicalTo(1), self::identicalTo(2)) + ->with(self::identicalTo($associationMapping), self::identicalTo($owner), self::identicalTo(1), self::identicalTo(2)) ->willReturn([$entity]); - self::assertSame([$entity], $persister->getOneToManyCollection([], $owner, 1, 2)); + self::assertSame([$entity], $persister->getOneToManyCollection($associationMapping, $owner, 1, 2)); } public function testInvokeLoadManyToManyCollection(): void { $mapping = $this->em->getClassMetadata(Country::class); - $assoc = ['type' => 1]; + $assoc = new OneToOneAssociationMapping('foo', 'bar', 'baz'); $coll = new PersistentCollection($this->em, $mapping, new ArrayCollection()); $persister = $this->createPersisterDefault(); $entity = new Country('Foo'); @@ -365,7 +382,7 @@ public function testInvokeLoadManyToManyCollection(): void public function testInvokeLoadOneToManyCollection(): void { $mapping = $this->em->getClassMetadata(Country::class); - $assoc = ['type' => 1]; + $assoc = new OneToOneAssociationMapping('foo', 'bar', 'baz'); $coll = new PersistentCollection($this->em, $mapping, new ArrayCollection()); $persister = $this->createPersisterDefault(); $entity = new Country('Foo'); diff --git a/tests/Doctrine/Tests/ORM/Functional/ManyToManyBasicAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/ManyToManyBasicAssociationTest.php index d27697b510f..f5f01625fa7 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ManyToManyBasicAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ManyToManyBasicAssociationTest.php @@ -6,6 +6,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Criteria; +use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\UnitOfWork; use Doctrine\Tests\Models\CMS\CmsGroup; @@ -265,7 +266,7 @@ public function testWorkWithDqlHydratedEmptyCollection(): void ->setParameter(1, $user->getId()) ->getSingleResult(); self::assertCount(0, $newUser->groups); - self::assertIsArray($newUser->groups->getMapping()); + self::assertInstanceOf(AssociationMapping::class, $newUser->groups->getMapping()); $newUser->addGroup($group); diff --git a/tests/Doctrine/Tests/ORM/Mapping/AnsiQuoteStrategyTest.php b/tests/Doctrine/Tests/ORM/Mapping/AnsiQuoteStrategyTest.php index 8b8c813a537..5f3da208862 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/AnsiQuoteStrategyTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/AnsiQuoteStrategyTest.php @@ -104,7 +104,7 @@ public function testJoinColumnName(): void 'fieldName' => 'article', 'targetEntity' => DDC117Article::class, 'joinColumns' => [ - ['name' => 'article'], + ['name' => 'article', 'referencedColumnName' => 'id'], ], ], ); @@ -123,7 +123,7 @@ public function testReferencedJoinColumnName(): void 'fieldName' => 'article', 'targetEntity' => DDC117Article::class, 'joinColumns' => [ - ['name' => 'article'], + ['name' => 'article', 'referencedColumnName' => 'id'], ], ], ); diff --git a/tests/Doctrine/Tests/ORM/Mapping/AssociationMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/AssociationMappingTest.php new file mode 100644 index 00000000000..b129a2cb60e --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/AssociationMappingTest.php @@ -0,0 +1,104 @@ +mappedBy = 'foo'; + $mapping->inversedBy = 'bar'; + $mapping->cascade = ['persist']; + $mapping->fetch = ClassMetadata::FETCH_EAGER; + $mapping->inherited = self::class; + $mapping->declared = self::class; + $mapping->cache = ['usage' => ClassMetadata::CACHE_USAGE_READ_ONLY]; + $mapping->id = true; + $mapping->isOnDeleteCascade = true; + $mapping->joinColumnFieldNames = ['foo' => 'bar']; + $mapping->joinTableColumns = ['foo', 'bar']; + $mapping->originalClass = self::class; + $mapping->originalField = 'foo'; + $mapping->orphanRemoval = true; + $mapping->unique = true; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof AssociationMapping); + + self::assertSame('foo', $resurrectedMapping->mappedBy); + self::assertSame('bar', $resurrectedMapping->inversedBy); + self::assertSame(['persist'], $resurrectedMapping->cascade); + self::assertSame(ClassMetadata::FETCH_EAGER, $resurrectedMapping->fetch); + self::assertSame(self::class, $resurrectedMapping->inherited); + self::assertSame(self::class, $resurrectedMapping->declared); + self::assertSame(['usage' => ClassMetadata::CACHE_USAGE_READ_ONLY], $resurrectedMapping->cache); + self::assertTrue($resurrectedMapping->id); + self::assertTrue($resurrectedMapping->isOnDeleteCascade); + self::assertSame(['foo' => 'bar'], $resurrectedMapping->joinColumnFieldNames); + self::assertSame(['foo', 'bar'], $resurrectedMapping->joinTableColumns); + self::assertSame(self::class, $resurrectedMapping->originalClass); + self::assertSame('foo', $resurrectedMapping->originalField); + self::assertTrue($resurrectedMapping->orphanRemoval); + self::assertTrue($resurrectedMapping->unique); + } + + public function testItThrowsWhenAccessingUnknownProperty(): void + { + $mapping = new MyAssociationMapping( + fieldName: 'foo', + sourceEntity: self::class, + targetEntity: self::class, + ); + + $this->expectException(OutOfRangeException::class); + + $mapping['foo']; + } + + public function testItThrowsWhenSettingUnknownProperty(): void + { + $mapping = new MyAssociationMapping( + fieldName: 'foo', + sourceEntity: self::class, + targetEntity: self::class, + ); + + $this->expectException(OutOfRangeException::class); + + $mapping['foo'] = 'bar'; + } + + public function testItThrowsWhenUnsettingUnknownProperty(): void + { + $mapping = new MyAssociationMapping( + fieldName: 'foo', + sourceEntity: self::class, + targetEntity: self::class, + ); + + $this->expectException(OutOfRangeException::class); + + unset($mapping['foo']); + } +} + +class MyAssociationMapping extends AssociationMapping +{ +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php index a7dc1b69bbf..5644da253f9 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php @@ -11,7 +11,11 @@ use Doctrine\ORM\Mapping\DiscriminatorColumnMapping; use Doctrine\ORM\Mapping\EmbeddedClassMapping; use Doctrine\ORM\Mapping\FieldMapping; +use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; +use Doctrine\ORM\Mapping\ManyToOneAssociationMapping; use Doctrine\ORM\Mapping\MappingException; +use Doctrine\ORM\Mapping\OneToManyAssociationMapping; +use Doctrine\ORM\Mapping\OneToOneOwningSideMapping; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Doctrine\Tests\Models\CMS\CmsGroup; use Doctrine\Tests\Models\CMS\CmsUser; @@ -298,7 +302,7 @@ public function testCreateManyToOne(): void self::assertEquals( [ - 'groups' => [ + 'groups' => ManyToOneAssociationMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => [ @@ -325,11 +329,6 @@ public function testCreateManyToOne(): void 'inversedBy' => null, 'isOwningSide' => true, 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => true, - 'isCascadePersist' => true, - 'isCascadeRefresh' => true, - 'isCascadeMerge' => true, - 'isCascadeDetach' => true, 'sourceToTargetKeyColumns' => ['group_id' => 'id'], 'joinColumnFieldNames' => @@ -337,7 +336,7 @@ public function testCreateManyToOne(): void 'targetToSourceKeyColumns' => ['id' => 'group_id'], 'orphanRemoval' => false, - ], + ]), ], $this->cm->associationMappings, ); @@ -358,7 +357,7 @@ public function testCreateManyToOneWithIdentity(): void self::assertEquals( [ - 'groups' => [ + 'groups' => ManyToOneAssociationMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => [ @@ -385,11 +384,6 @@ public function testCreateManyToOneWithIdentity(): void 'inversedBy' => null, 'isOwningSide' => true, 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => true, - 'isCascadePersist' => true, - 'isCascadeRefresh' => true, - 'isCascadeMerge' => true, - 'isCascadeDetach' => true, 'sourceToTargetKeyColumns' => ['group_id' => 'id'], 'joinColumnFieldNames' => @@ -398,7 +392,7 @@ public function testCreateManyToOneWithIdentity(): void ['id' => 'group_id'], 'orphanRemoval' => false, 'id' => true, - ], + ]), ], $this->cm->associationMappings, ); @@ -416,7 +410,7 @@ public function testCreateOneToOne(): void self::assertEquals( [ - 'groups' => [ + 'groups' => OneToOneOwningSideMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => [ @@ -443,11 +437,6 @@ public function testCreateOneToOne(): void 'inversedBy' => null, 'isOwningSide' => true, 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => true, - 'isCascadePersist' => true, - 'isCascadeRefresh' => true, - 'isCascadeMerge' => true, - 'isCascadeDetach' => true, 'sourceToTargetKeyColumns' => ['group_id' => 'id'], 'joinColumnFieldNames' => @@ -455,7 +444,7 @@ public function testCreateOneToOne(): void 'targetToSourceKeyColumns' => ['id' => 'group_id'], 'orphanRemoval' => false, - ], + ]), ], $this->cm->associationMappings, ); @@ -476,7 +465,7 @@ public function testCreateOneToOneWithIdentity(): void self::assertEquals( [ - 'groups' => [ + 'groups' => OneToOneOwningSideMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => [ @@ -504,11 +493,6 @@ public function testCreateOneToOneWithIdentity(): void 'inversedBy' => null, 'isOwningSide' => true, 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => true, - 'isCascadePersist' => true, - 'isCascadeRefresh' => true, - 'isCascadeMerge' => true, - 'isCascadeDetach' => true, 'sourceToTargetKeyColumns' => ['group_id' => 'id'], 'joinColumnFieldNames' => @@ -516,7 +500,7 @@ public function testCreateOneToOneWithIdentity(): void 'targetToSourceKeyColumns' => ['id' => 'group_id'], 'orphanRemoval' => false, - ], + ]), ], $this->cm->associationMappings, ); @@ -550,7 +534,7 @@ public function testCreateManyToMany(): void self::assertEquals( [ 'groups' => - [ + ManyToManyOwningSideMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => @@ -594,13 +578,8 @@ public function testCreateManyToMany(): void 'mappedBy' => null, 'inversedBy' => null, 'isOwningSide' => true, - 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => true, - 'isCascadePersist' => true, - 'isCascadeRefresh' => true, - 'isCascadeMerge' => true, - 'isCascadeDetach' => true, 'isOnDeleteCascade' => true, + 'sourceEntity' => CmsUser::class, 'relationToSourceKeyColumns' => ['group_id' => 'id'], 'joinTableColumns' => @@ -611,7 +590,7 @@ public function testCreateManyToMany(): void 'relationToTargetKeyColumns' => ['user_id' => 'id'], 'orphanRemoval' => false, - ], + ]), ], $this->cm->associationMappings, ); @@ -644,27 +623,20 @@ public function testCreateOneToMany(): void self::assertEquals( [ 'groups' => - [ + OneToManyAssociationMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'mappedBy' => 'test', - 'orderBy' => - [0 => 'test'], + 'orderBy' => [0 => 'test'], 'indexBy' => 'test', 'type' => 4, 'inversedBy' => null, 'isOwningSide' => false, 'sourceEntity' => CmsUser::class, 'fetch' => 2, - 'cascade' => - [], - 'isCascadeRemove' => false, - 'isCascadePersist' => false, - 'isCascadeRefresh' => false, - 'isCascadeMerge' => false, - 'isCascadeDetach' => false, + 'cascade' => [], 'orphanRemoval' => false, - ], + ]), ], $this->cm->associationMappings, ); @@ -694,7 +666,7 @@ public function testOrphanRemovalOnCreateOneToOne(): void self::assertEquals( [ - 'groups' => [ + 'groups' => OneToOneOwningSideMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => [], @@ -715,11 +687,6 @@ public function testOrphanRemovalOnCreateOneToOne(): void 'inversedBy' => null, 'isOwningSide' => true, 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => true, - 'isCascadePersist' => false, - 'isCascadeRefresh' => false, - 'isCascadeMerge' => false, - 'isCascadeDetach' => false, 'sourceToTargetKeyColumns' => ['group_id' => 'id'], 'joinColumnFieldNames' => @@ -727,7 +694,7 @@ public function testOrphanRemovalOnCreateOneToOne(): void 'targetToSourceKeyColumns' => ['id' => 'group_id'], 'orphanRemoval' => true, - ], + ]), ], $this->cm->associationMappings, ); @@ -746,7 +713,7 @@ public function testOrphanRemovalOnCreateOneToMany(): void self::assertEquals( [ 'groups' => - [ + OneToManyAssociationMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'mappedBy' => 'test', @@ -756,13 +723,8 @@ public function testOrphanRemovalOnCreateOneToMany(): void 'sourceEntity' => CmsUser::class, 'fetch' => 2, 'cascade' => [], - 'isCascadeRemove' => true, - 'isCascadePersist' => false, - 'isCascadeRefresh' => false, - 'isCascadeMerge' => false, - 'isCascadeDetach' => false, 'orphanRemoval' => true, - ], + ]), ], $this->cm->associationMappings, ); @@ -789,7 +751,7 @@ public function testOrphanRemovalOnManyToMany(): void self::assertEquals( [ - 'groups' => [ + 'groups' => ManyToManyOwningSideMapping::fromMappingArray([ 'fieldName' => 'groups', 'targetEntity' => CmsGroup::class, 'cascade' => [], @@ -819,11 +781,6 @@ public function testOrphanRemovalOnManyToMany(): void 'inversedBy' => null, 'isOwningSide' => true, 'sourceEntity' => CmsUser::class, - 'isCascadeRemove' => false, - 'isCascadePersist' => false, - 'isCascadeRefresh' => false, - 'isCascadeMerge' => false, - 'isCascadeDetach' => false, 'isOnDeleteCascade' => true, 'relationToSourceKeyColumns' => ['group_id' => 'id'], 'joinTableColumns' => [ @@ -832,7 +789,7 @@ public function testOrphanRemovalOnManyToMany(): void ], 'relationToTargetKeyColumns' => ['cmsgroup_id' => 'id'], 'orphanRemoval' => true, - ], + ]), ], $this->cm->associationMappings, ); diff --git a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php index 948ba2d5a96..149045095df 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php @@ -13,6 +13,8 @@ use Doctrine\ORM\Mapping\DefaultNamingStrategy; use Doctrine\ORM\Mapping\DefaultTypedFieldMapper; use Doctrine\ORM\Mapping\DiscriminatorColumnMapping; +use Doctrine\ORM\Mapping\JoinTableMapping; +use Doctrine\ORM\Mapping\ManyToOneAssociationMapping; use Doctrine\ORM\Mapping\MappedSuperclass; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\UnderscoreNamingStrategy; @@ -141,7 +143,7 @@ public function testFieldIsNullableByType(): void $cm = new ClassMetadata(UserTyped::class); $cm->initializeReflection(new RuntimeReflectionService()); - $cm->mapOneToOne(['fieldName' => 'email', 'joinColumns' => [[]]]); + $cm->mapOneToOne(['fieldName' => 'email', 'joinColumns' => [['name' => 'email_id', 'referencedColumnName' => 'id']]]); self::assertEquals(CmsEmail::class, $cm->getAssociationMapping('email')['targetEntity']); $cm->mapManyToOne(['fieldName' => 'mainEmail']); @@ -264,11 +266,11 @@ public function testMapManyToManyJoinTableDefaults(): void $assoc = $cm->associationMappings['groups']; self::assertEquals( - [ + JoinTableMapping::fromMappingArray([ 'name' => 'cmsuser_cmsgroup', 'joinColumns' => [['name' => 'cmsuser_id', 'referencedColumnName' => 'id', 'onDelete' => 'CASCADE']], 'inverseJoinColumns' => [['name' => 'cmsgroup_id', 'referencedColumnName' => 'id', 'onDelete' => 'CASCADE']], - ], + ]), $assoc['joinTable'], ); self::assertTrue($assoc['isOnDeleteCascade']); @@ -354,8 +356,18 @@ public function testDuplicateAssociationMappingException(): void $cm = new ClassMetadata(CmsUser::class); $cm->initializeReflection(new RuntimeReflectionService()); - $a1 = ['fieldName' => 'foo', 'sourceEntity' => stdClass::class, 'targetEntity' => stdClass::class, 'mappedBy' => 'foo']; - $a2 = ['fieldName' => 'foo', 'sourceEntity' => stdClass::class, 'targetEntity' => stdClass::class, 'mappedBy' => 'foo']; + $a1 = ManyToOneAssociationMapping::fromMappingArray([ + 'fieldName' => 'foo', + 'sourceEntity' => stdClass::class, + 'targetEntity' => stdClass::class, + 'mappedBy' => 'foo', + ]); + $a2 = ManyToOneAssociationMapping::fromMappingArray([ + 'fieldName' => 'foo', + 'sourceEntity' => stdClass::class, + 'targetEntity' => stdClass::class, + 'mappedBy' => 'foo', + ]); $cm->addInheritedAssociationMapping($a1); $this->expectException(MappingException::class); @@ -858,11 +870,11 @@ public function testManyToManySelfReferencingNamingStrategyDefaults(): void ); self::assertEquals( - [ + JoinTableMapping::fromMappingArray([ 'name' => 'customtypeparent_customtypeparent', 'joinColumns' => [['name' => 'customtypeparent_source', 'referencedColumnName' => 'id', 'onDelete' => 'CASCADE']], 'inverseJoinColumns' => [['name' => 'customtypeparent_target', 'referencedColumnName' => 'id', 'onDelete' => 'CASCADE']], - ], + ]), $cm->associationMappings['friendsWithMe']['joinTable'], ); self::assertEquals(['customtypeparent_source', 'customtypeparent_target'], $cm->associationMappings['friendsWithMe']['joinTableColumns']); diff --git a/tests/Doctrine/Tests/ORM/Mapping/JoinColumnMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/JoinColumnMappingTest.php new file mode 100644 index 00000000000..e79d1b44be5 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/JoinColumnMappingTest.php @@ -0,0 +1,43 @@ +name = 'foo'; + $mapping->unique = true; + $mapping->quoted = true; + $mapping->fieldName = 'bar'; + $mapping->onDelete = 'CASCADE'; + $mapping->columnDefinition = 'VARCHAR(255)'; + $mapping->nullable = true; + $mapping->referencedColumnName = 'baz'; + $mapping->options = ['foo' => 'bar']; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof JoinColumnMapping); + + self::assertSame('foo', $resurrectedMapping->name); + self::assertTrue($resurrectedMapping->unique); + self::assertTrue($resurrectedMapping->quoted); + self::assertSame('bar', $resurrectedMapping->fieldName); + self::assertSame('CASCADE', $resurrectedMapping->onDelete); + self::assertSame('VARCHAR(255)', $resurrectedMapping->columnDefinition); + self::assertTrue($resurrectedMapping->nullable); + self::assertSame('baz', $resurrectedMapping->referencedColumnName); + self::assertSame(['foo' => 'bar'], $resurrectedMapping->options); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/JoinTableMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/JoinTableMappingTest.php new file mode 100644 index 00000000000..846605cf9fd --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/JoinTableMappingTest.php @@ -0,0 +1,36 @@ +quoted = true; + $mapping->joinColumns = [new JoinColumnMapping('id')]; + $mapping->inverseJoinColumns = [new JoinColumnMapping('id')]; + $mapping->schema = 'foo'; + $mapping->name = 'bar'; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof JoinTableMapping); + + self::assertTrue($resurrectedMapping->quoted); + self::assertCount(1, $resurrectedMapping->joinColumns); + self::assertCount(1, $resurrectedMapping->inverseJoinColumns); + self::assertSame('foo', $resurrectedMapping->schema); + self::assertSame('bar', $resurrectedMapping->name); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/ManyToManyAssociationMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/ManyToManyAssociationMappingTest.php new file mode 100644 index 00000000000..01b1ac22e8d --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/ManyToManyAssociationMappingTest.php @@ -0,0 +1,33 @@ +relationToSourceKeyColumns = ['foo' => 'bar']; + $mapping->relationToTargetKeyColumns = ['bar' => 'baz']; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof ManyToManyAssociationMapping); + + self::assertSame(['foo' => 'bar'], $resurrectedMapping->relationToSourceKeyColumns); + self::assertSame(['bar' => 'baz'], $resurrectedMapping->relationToTargetKeyColumns); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php new file mode 100644 index 00000000000..77f44121448 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php @@ -0,0 +1,33 @@ +joinTable = new JoinTableMapping(); + $mapping->joinTable->name = 'bar'; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof ManyToManyOwningSideMapping); + + self::assertSame($resurrectedMapping->joinTable->name, 'bar'); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php new file mode 100644 index 00000000000..02831a3b198 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php @@ -0,0 +1,32 @@ +joinColumns = [new JoinColumnMapping('id')]; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof ManyToOneAssociationMapping); + + self::assertCount(1, $resurrectedMapping->joinColumns); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php new file mode 100644 index 00000000000..fb7c9cc461a --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php @@ -0,0 +1,32 @@ +joinColumns = [new JoinColumnMapping('id')]; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof OneToOneOwningSideMapping); + + self::assertCount(1, $resurrectedMapping->joinColumns); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/QuoteStrategyTest.php b/tests/Doctrine/Tests/ORM/Mapping/QuoteStrategyTest.php index 2266ea828bf..74860086112 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/QuoteStrategyTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/QuoteStrategyTest.php @@ -147,7 +147,7 @@ public function testQuoteIdentifierJoinColumns(): void 'fieldName' => 'article', 'targetEntity' => DDC117Article::class, 'joinColumns' => [ - ['name' => '`article`'], + ['name' => '`article`', 'referencedColumnName' => 'article'], ], ], ); @@ -165,7 +165,7 @@ public function testJoinColumnName(): void 'fieldName' => 'article', 'targetEntity' => DDC117Article::class, 'joinColumns' => [ - ['name' => '`article`'], + ['name' => '`article`', 'referencedColumnName' => 'article'], ], ], ); @@ -184,7 +184,7 @@ public function testReferencedJoinColumnName(): void 'fieldName' => 'article', 'targetEntity' => DDC117Article::class, 'joinColumns' => [ - ['name' => '`article`'], + ['name' => '`article`', 'referencedColumnName' => 'id'], ], ], ); diff --git a/tests/Doctrine/Tests/ORM/Mapping/ToManyAssociationMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/ToManyAssociationMappingTest.php new file mode 100644 index 00000000000..8befdde20f0 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/ToManyAssociationMappingTest.php @@ -0,0 +1,37 @@ +indexBy = 'foo'; + $mapping->orderBy = ['foo' => 'asc']; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof ToManyAssociationMapping); + + self::assertSame('foo', $resurrectedMapping->fieldName); + self::assertSame(['foo' => 'asc'], $resurrectedMapping->orderBy); + } +} + +class MyToManyAssociationMapping extends ToManyAssociationMapping +{ +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/ToOneAssociationMappingTest.php b/tests/Doctrine/Tests/ORM/Mapping/ToOneAssociationMappingTest.php new file mode 100644 index 00000000000..32858ea577c --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/ToOneAssociationMappingTest.php @@ -0,0 +1,37 @@ +sourceToTargetKeyColumns = ['foo' => 'bar']; + $mapping->targetToSourceKeyColumns = ['bar' => 'foo']; + + $resurrectedMapping = unserialize(serialize($mapping)); + assert($resurrectedMapping instanceof ToOneAssociationMapping); + + self::assertSame(['foo' => 'bar'], $resurrectedMapping->sourceToTargetKeyColumns); + self::assertSame(['bar' => 'foo'], $resurrectedMapping->targetToSourceKeyColumns); + } +} + +class MyToOneAssociationMapping extends ToOneAssociationMapping +{ +} diff --git a/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php b/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php index d1f82e74cd9..a4ce25192dc 100644 --- a/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php +++ b/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM; +use Doctrine\ORM\Mapping\OneToManyAssociationMapping; use Doctrine\ORM\ORMInvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -46,21 +47,21 @@ public function __toString(): string return 'ThisIsAStringRepresentationOfEntity3'; } }; - $association1 = [ + $association1 = OneToManyAssociationMapping::fromMappingArray([ 'sourceEntity' => 'foo1', 'fieldName' => 'bar1', 'targetEntity' => 'baz1', - ]; - $association2 = [ + ]); + $association2 = OneToManyAssociationMapping::fromMappingArray([ 'sourceEntity' => 'foo2', 'fieldName' => 'bar2', 'targetEntity' => 'baz2', - ]; - $association3 = [ + ]); + $association3 = OneToManyAssociationMapping::fromMappingArray([ 'sourceEntity' => 'foo3', 'fieldName' => 'bar3', 'targetEntity' => 'baz3', - ]; + ]); return [ 'one entity found' => [ diff --git a/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterCompositeTypeSqlTest.php b/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterCompositeTypeSqlTest.php index 4dafeda7f8d..9ab9e32c86b 100644 --- a/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterCompositeTypeSqlTest.php +++ b/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterCompositeTypeSqlTest.php @@ -5,6 +5,8 @@ namespace Doctrine\Tests\ORM\Persisters; use Doctrine\Common\Collections\Expr\Comparison; +use Doctrine\ORM\Mapping\AssociationMapping; +use Doctrine\ORM\Mapping\ManyToOneAssociationMapping; use Doctrine\ORM\Persisters\Entity\BasicEntityPersister; use Doctrine\ORM\Persisters\Exception\CantUseInOperatorOnCompositeKeys; use Doctrine\Tests\Mocks\EntityManagerMock; @@ -15,36 +17,42 @@ class BasicEntityPersisterCompositeTypeSqlTest extends OrmTestCase { protected BasicEntityPersister $persister; protected EntityManagerMock $entityManager; + private AssociationMapping $associationMapping; protected function setUp(): void { parent::setUp(); - $this->entityManager = $this->getTestEntityManager(); - $this->persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Admin1AlternateName::class)); + $this->entityManager = $this->getTestEntityManager(); + $this->persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Admin1AlternateName::class)); + $this->associationMapping = new ManyToOneAssociationMapping( + fieldName: 'admin1', + sourceEntity: WhoCares::class, + targetEntity: Admin1AlternateName::class, + ); } public function testSelectConditionStatementEq(): void { - $statement = $this->persister->getSelectConditionStatementSQL('admin1', 1, [], Comparison::EQ); + $statement = $this->persister->getSelectConditionStatementSQL('admin1', 1, $this->associationMapping, Comparison::EQ); self::assertEquals('t0.admin1 = ? AND t0.country = ?', $statement); } public function testSelectConditionStatementEqNull(): void { - $statement = $this->persister->getSelectConditionStatementSQL('admin1', null, [], Comparison::IS); + $statement = $this->persister->getSelectConditionStatementSQL('admin1', null, $this->associationMapping, Comparison::IS); self::assertEquals('t0.admin1 IS NULL AND t0.country IS NULL', $statement); } public function testSelectConditionStatementNeqNull(): void { - $statement = $this->persister->getSelectConditionStatementSQL('admin1', null, [], Comparison::NEQ); + $statement = $this->persister->getSelectConditionStatementSQL('admin1', null, $this->associationMapping, Comparison::NEQ); self::assertEquals('t0.admin1 IS NOT NULL AND t0.country IS NOT NULL', $statement); } public function testSelectConditionStatementIn(): void { $this->expectException(CantUseInOperatorOnCompositeKeys::class); - $this->persister->getSelectConditionStatementSQL('admin1', [], [], Comparison::IN); + $this->persister->getSelectConditionStatementSQL('admin1', [], $this->associationMapping, Comparison::IN); } } diff --git a/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterTypeValueSqlTest.php b/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterTypeValueSqlTest.php index 35d9482b909..3bfbbeacdd5 100644 --- a/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterTypeValueSqlTest.php +++ b/tests/Doctrine/Tests/ORM/Persisters/BasicEntityPersisterTypeValueSqlTest.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Driver; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type as DBALType; +use Doctrine\ORM\Mapping\OneToManyAssociationMapping; use Doctrine\ORM\Persisters\Entity\BasicEntityPersister; use Doctrine\Tests\DbalTypes\NegativeToPositiveType; use Doctrine\Tests\DbalTypes\UpperCaseStringType; @@ -119,19 +120,27 @@ public function testStripNonAlphanumericCharactersFromSelectColumnListSQL(): voi #[Group('DDC-2073')] public function testSelectConditionStatementIsNull(): void { - $statement = $this->persister->getSelectConditionStatementSQL('test', null, [], Comparison::IS); + $associationMapping = new OneToManyAssociationMapping('foo', 'bar', 'baz'); + $statement = $this->persister->getSelectConditionStatementSQL('test', null, $associationMapping, Comparison::IS); self::assertEquals('test IS NULL', $statement); } public function testSelectConditionStatementEqNull(): void { - $statement = $this->persister->getSelectConditionStatementSQL('test', null, [], Comparison::EQ); + $associationMapping = new OneToManyAssociationMapping('foo', 'bar', 'baz'); + $statement = $this->persister->getSelectConditionStatementSQL('test', null, $associationMapping, Comparison::EQ); self::assertEquals('test IS NULL', $statement); } public function testSelectConditionStatementNeqNull(): void { - $statement = $this->persister->getSelectConditionStatementSQL('test', null, [], Comparison::NEQ); + $associationMapping = new OneToManyAssociationMapping('foo', 'bar', 'baz'); + $statement = $this->persister->getSelectConditionStatementSQL( + 'test', + null, + $associationMapping, + Comparison::NEQ, + ); self::assertEquals('test IS NOT NULL', $statement); }