Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use array shapes where appropriate #10513

Merged
merged 1 commit into from
Feb 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/Doctrine/ORM/Cache/CacheFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

/**
* Contract for building second level cache regions components.
*
* @psalm-import-type AssociationMapping from ClassMetadata
*/
interface CacheFactory
{
Expand All @@ -31,7 +33,7 @@ public function buildCachedEntityPersister(EntityManagerInterface $em, EntityPer
/**
* Build a collection persister for the given relation mapping.
*
* @param mixed[] $mapping The association mapping.
* @param AssociationMapping $mapping The association mapping.
*
* @return CachedCollectionPersister
*/
Expand Down
1 change: 1 addition & 0 deletions lib/Doctrine/ORM/Cache/DefaultCacheFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public function buildCachedEntityPersister(EntityManagerInterface $em, EntityPer
*/
public function buildCachedCollectionPersister(EntityManagerInterface $em, CollectionPersister $persister, array $mapping)
{
assert(isset($mapping['cache']));
$usage = $mapping['cache']['usage'];
$region = $this->getRegion($mapping['cache']);

Expand Down
6 changes: 4 additions & 2 deletions lib/Doctrine/ORM/Cache/DefaultQueryCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

/**
* Default query cache implementation.
*
* @psalm-import-type AssociationMapping from ClassMetadata
*/
class DefaultQueryCache implements QueryCache
{
Expand Down Expand Up @@ -326,8 +328,8 @@ public function put(QueryCacheKey $key, ResultSetMapping $rsm, $result, array $h
}

/**
* @param array<string,mixed> $assoc
* @param mixed $assocValue
* @param AssociationMapping $assoc
* @param mixed $assocValue
*
* @return mixed[]|null
* @psalm-return array{targetEntity: class-string, type: mixed, list?: array[], identifier?: array}|null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use function assert;
use function count;

/** @psalm-import-type AssociationMapping from ClassMetadata */
abstract class AbstractCollectionPersister implements CachedCollectionPersister
{
/** @var UnitOfWork */
Expand Down Expand Up @@ -64,7 +65,7 @@ abstract class AbstractCollectionPersister implements CachedCollectionPersister
* @param CollectionPersister $persister The collection persister that will be cached.
* @param Region $region The collection region.
* @param EntityManagerInterface $em The entity manager.
* @param mixed[] $association The association mapping.
* @param AssociationMapping $association The association mapping.
*/
public function __construct(CollectionPersister $persister, Region $region, EntityManagerInterface $em, array $association)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
use Doctrine\ORM\Cache\CollectionCacheKey;
use Doctrine\ORM\Cache\ConcurrentRegion;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
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 mixed[] $association The association mapping. */
/** @param AssociationMapping $association The association mapping. */
public function __construct(CollectionPersister $persister, ConcurrentRegion $region, EntityManagerInterface $em, array $association)
{
parent::__construct($persister, $region, $em, $association);
Expand Down
73 changes: 6 additions & 67 deletions lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -1855,30 +1855,7 @@ protected function _validateAndCompleteAssociationMapping(array $mapping)
* @psalm-param array<string, mixed> $mapping The mapping to validate & complete.
*
* @return mixed[] The validated & completed mapping.
* @psalm-return array{
* mappedBy: mixed|null,
* inversedBy: mixed|null,
* isOwningSide: bool,
* sourceEntity: class-string,
* targetEntity: string,
* fieldName: mixed,
* fetch: mixed,
* cascade: array<string>,
* isCascadeRemove: bool,
* isCascadePersist: bool,
* isCascadeRefresh: bool,
* isCascadeMerge: bool,
* isCascadeDetach: bool,
* type: int,
* originalField: string,
* originalClass: class-string,
* joinColumns?: array{0: array{name: string, referencedColumnName: string}}|mixed,
* id?: mixed,
* sourceToTargetKeyColumns?: array<string, string>,
* joinColumnFieldNames?: array<string, string>,
* targetToSourceKeyColumns?: array<string, string>,
* orphanRemoval: bool
* }
* @psalm-return AssociationMapping
*
* @throws RuntimeException
* @throws MappingException
Expand Down Expand Up @@ -1968,22 +1945,7 @@ protected function _validateAndCompleteOneToOneMapping(array $mapping)
* @psalm-param array<string, mixed> $mapping The mapping to validate and complete.
*
* @return mixed[] The validated and completed mapping.
* @psalm-return array{
* mappedBy: mixed,
* inversedBy: mixed,
* isOwningSide: bool,
* sourceEntity: string,
* targetEntity: string,
* fieldName: mixed,
* fetch: int|mixed,
* cascade: array<array-key,string>,
* isCascadeRemove: bool,
* isCascadePersist: bool,
* isCascadeRefresh: bool,
* isCascadeMerge: bool,
* isCascadeDetach: bool,
* orphanRemoval: bool
* }
* @psalm-return AssociationMapping
*
* @throws MappingException
* @throws InvalidArgumentException
Expand Down Expand Up @@ -2011,30 +1973,7 @@ protected function _validateAndCompleteOneToManyMapping(array $mapping)
* @psalm-param array<string, mixed> $mapping The mapping to validate & complete.
*
* @return mixed[] The validated & completed mapping.
* @psalm-return array{
* mappedBy: mixed,
* inversedBy: mixed,
* isOwningSide: bool,
* sourceEntity: class-string,
* targetEntity: string,
* fieldName: mixed,
* fetch: mixed,
* cascade: array<string>,
* isCascadeRemove: bool,
* isCascadePersist: bool,
* isCascadeRefresh: bool,
* isCascadeMerge: bool,
* isCascadeDetach: bool,
* type: int,
* originalField: string,
* originalClass: class-string,
* joinTable?: array{inverseJoinColumns: mixed}|mixed,
* joinTableColumns?: list<mixed>,
* isOnDeleteCascade?: true,
* relationToSourceKeyColumns?: array,
* relationToTargetKeyColumns?: array,
* orphanRemoval: bool
* }
* @psalm-return AssociationMapping
*
* @throws InvalidArgumentException
*/
Expand Down Expand Up @@ -3033,7 +2972,7 @@ public function mapManyToMany(array $mapping)
/**
* Stores the association mapping.
*
* @psalm-param array<string, mixed> $assocMapping
* @psalm-param AssociationMapping $assocMapping
*
* @return void
*
Expand Down Expand Up @@ -3185,7 +3124,7 @@ public function addEntityListener($eventName, $class, $method)
* @see getDiscriminatorColumn()
*
* @param mixed[]|null $columnDef
* @psalm-param array{name: string|null, fieldName?: string, type?: string, length?: int, columnDefinition?: string|null, enumType?: class-string<BackedEnum>|null}|null $columnDef
* @psalm-param DiscriminatorColumnMapping|array{name: string|null, fieldName?: string, type?: string, length?: int, columnDefinition?: string|null, enumType?: class-string<BackedEnum>|null}|null $columnDef
*
* @return void
*
Expand Down Expand Up @@ -3891,7 +3830,7 @@ public function getSequencePrefix(AbstractPlatform $platform)
return $sequencePrefix;
}

/** @psalm-param array<string, mixed> $mapping */
/** @psalm-param AssociationMapping $mapping */
private function assertMappingOrderBy(array $mapping): void
{
if (isset($mapping['orderBy']) && ! is_array($mapping['orderBy'])) {
Expand Down
9 changes: 6 additions & 3 deletions lib/Doctrine/ORM/Mapping/QuoteStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

/**
* 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
{
Expand Down Expand Up @@ -39,7 +42,7 @@ public function getSequenceName(array $definition, ClassMetadata $class, Abstrac
/**
* Gets the (possibly quoted) name of the join table.
*
* @param mixed[] $association
* @param AssociationMapping $association
*
* @return string
*/
Expand All @@ -48,7 +51,7 @@ public function getJoinTableName(array $association, ClassMetadata $class, Abstr
/**
* Gets the (possibly quoted) join column name.
*
* @param mixed[] $joinColumn
* @param JoinColumnData $joinColumn
*
* @return string
*/
Expand All @@ -57,7 +60,7 @@ public function getJoinColumnName(array $joinColumn, ClassMetadata $class, Abstr
/**
* Gets the (possibly quoted) join column name.
*
* @param mixed[] $joinColumn
* @param JoinColumnData $joinColumn
*
* @return string
*/
Expand Down
10 changes: 5 additions & 5 deletions lib/Doctrine/ORM/ORMInvalidArgumentException.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ public static function readOnlyRequiresManagedEntity($entity)
}

/**
* @param array[][]|object[][] $newEntitiesWithAssociations non-empty an array
* of [array $associationMapping, object $entity] pairs
* @psalm-param non-empty-list<array{AssociationMapping, object}> $newEntitiesWithAssociations non-empty an array
* of [array $associationMapping, object $entity] pairs
*
* @return ORMInvalidArgumentException
*/
Expand Down Expand Up @@ -122,7 +122,7 @@ public static function newEntityFoundThroughRelationship(array $associationMappi

/**
* @param object $entry
* @psalm-param array<string, string> $assoc
* @psalm-param AssociationMapping $assoc
*
* @return ORMInvalidArgumentException
*/
Expand Down Expand Up @@ -222,8 +222,8 @@ public static function invalidIdentifierBindingEntity(/* string $class */)
}

/**
* @param mixed[] $assoc
* @param mixed $actualValue
* @param AssociationMapping $assoc
* @param mixed $actualValue
*
* @return self
*/
Expand Down
7 changes: 4 additions & 3 deletions lib/Doctrine/ORM/PersistentCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
* @psalm-template T
* @template-extends AbstractLazyCollection<TKey,T>
* @template-implements Selectable<TKey,T>
* @psalm-import-type AssociationMapping from ClassMetadata
*/
final class PersistentCollection extends AbstractLazyCollection implements Selectable
{
Expand All @@ -58,7 +59,7 @@ 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 array<string, mixed>|null
* @psalm-var AssociationMapping|null
*/
private $association;

Expand Down Expand Up @@ -113,7 +114,7 @@ public function __construct(EntityManagerInterface $em, $class, Collection $coll
* describes the association between the owner and the elements of the collection.
*
* @param object $entity
* @psalm-param array<string, mixed> $assoc
* @psalm-param AssociationMapping $assoc
*/
public function setOwner($entity, array $assoc): void
{
Expand Down Expand Up @@ -271,7 +272,7 @@ public function getInsertDiff(): array
/**
* INTERNAL: Gets the association mapping of the collection.
*
* @psalm-return array<string, mixed>|null
* @psalm-return AssociationMapping|null
*/
public function getMapping(): ?array
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

/**
* Persister for many-to-many collections.
*
* @psalm-import-type AssociationMapping from ClassMetadata
Copy link
Member Author

@greg0ire greg0ire Feb 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most Psalm errors occur in this file, because Psalm does not know that joinTable is always set for mappings that represent the owning side of a many to many relationship.

As it stands, #10405 won't fix that either, because I did not create a separate type for owning sides, but maybe I should?

Before

classDiagram
    AssociationMapping <|-- OneToOneAssociationMapping
    AssociationMapping <|-- OneToManyAssociationMapping
    AssociationMapping <|-- ManyToOneAssociationMapping
    AssociationMapping <|-- ManyToManyAssociationMapping
Loading

After

For ManyToOne and for OneToMany, isOwningSide is redundant according to the docs. I think this could be modeled with an interface (not implementing it would mean the relationship is owned).

classDiagram
    AssociationMapping <|-- OneToOneAssociationMapping
    AssociationMapping <|-- OneToManyAssociationMapping
    AssociationMapping <|-- ManyToOneAssociationMapping
    AssociationMapping <|-- ManyToManyAssociationMapping
    ManyToManyAssociationMapping <|-- OwningManyToManyAssociationMapping
    ManyToManyAssociationMapping <|-- OwnedManyToManyAssociationMapping
    OneToOneAssociationMapping <|-- OwningOneToOneAssociationMapping
    OneToOneAssociationMapping <|-- OwnedOneToOneAssociationMapping
Loading

Cc @mpdude

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How could/would Psalm know when/where you're dealing with an AssociationMapping that represents the owning side? Would have have to make instanceof checks?

Copy link
Member Author

@greg0ire greg0ire Feb 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly! We would replace the guard check here, for instance:

if (! $mapping['isOwningSide']) {
return; // ignore inverse side
}
$types = [];
$class = $this->em->getClassMetadata($mapping['sourceEntity']);
foreach ($mapping['joinTable']['joinColumns'] as $joinColumn) {
$types[] = PersisterHelper::getTypeOfColumn($joinColumn['referencedColumnName'], $class, $this->em);
}

Looking at this, you can see how the owning and owned sides are very different in the case of the many to many relationship:

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;
}
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'] = $this->namingStrategy->joinKeyColumnName($mapping['targetEntity'], $inverseJoinColumn['referencedColumnName']);
}
if (empty($inverseJoinColumn['referencedColumnName'])) {
$inverseJoinColumn['referencedColumnName'] = $this->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'];
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then let's see that we define the class hierarchy not only by technical criteria (i.e. which fields are common in several classes and extract those into a base class), but from a semantical point of view. What kind of checks do we have in the codebase?

For example if ($mapping['isOwningSide']) ... would become if ($mapping instanceof AssociationOwningSide) ... or so.

Copy link
Member Author

@greg0ire greg0ire Feb 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have two kind of checks: you just mentioned the first one, and the other one is against $mapping['type'], sometimes with the & operator, for instance to detect if it's a to-many, for instance here:

$this->association['type'] & ClassMetadata::TO_MANY &&

We might need to introduce ToOneAssociationMapping and ToManyAssociationMapping interfaces. ATM I used those names for traits but I can change those to To{One,Many}AssociationMappingTrait I guess.

The thing is, interfaces can't have properties, so it's only a viable option if we switch to methods instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah but we only ever test for TO_MANY and TO_ONE, never for ONE_TO or MANY_TO (those constants don't even exist), so it should be possible to just include these as classes in the hierarchy. I will ditch the traits and do that instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a new commit on the other PR, switching from traits to classes.

The thing is, interfaces can't have properties, so it's only a viable option if we switch to methods instead.

Just realised that this might not be an issue because we should be able to change the phpdoc on ClassMetadata::AssociationMapping from array<string, AssociationMapping> to array<string, list-of-concrete-classes>. An instanceof check should allow SA to rule out some of the classes and to notice that some of them have common properties 🤞

*/
class ManyToManyPersister extends AbstractCollectionPersister
{
Expand Down Expand Up @@ -286,7 +288,7 @@ public function loadCriteria(PersistentCollection $collection, Criteria $criteri
* JOIN.
*
* @param mixed[] $mapping Array containing mapping information.
* @psalm-param array<string, mixed> $mapping
* @psalm-param AssociationMapping $mapping
*
* @return string[] ordered tuple:
* - JOIN condition to add to the SQL
Expand Down Expand Up @@ -339,7 +341,7 @@ protected function generateFilterConditionSQL(ClassMetadata $targetEntity, $targ
* Generate ON condition
*
* @param mixed[] $mapping
* @psalm-param array<string, mixed> $mapping
* @psalm-param AssociationMapping $mapping
*
* @return string[]
* @psalm-return list<string>
Expand Down
16 changes: 9 additions & 7 deletions lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
*
* 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
{
Expand Down Expand Up @@ -1332,9 +1334,9 @@ protected function getSelectColumnsSQL()
/**
* Gets the SQL join fragment used when selecting entities from an association.
*
* @param string $field
* @param mixed[] $assoc
* @param string $alias
* @param string $field
* @param AssociationMapping $assoc
* @param string $alias
*
* @return string
*/
Expand Down Expand Up @@ -1366,7 +1368,7 @@ protected function getSelectColumnAssociationSQL($field, $assoc, ClassMetadata $
* Gets the SQL join fragment used when selecting entities from a
* many-to-many association.
*
* @psalm-param array<string, mixed> $manyToMany
* @psalm-param AssociationMapping $manyToMany
*
* @return string
*/
Expand Down Expand Up @@ -1694,7 +1696,7 @@ public function getSelectConditionStatementSQL($field, $value, $assoc = null, $c
/**
* Builds the left-hand-side of a where condition statement.
*
* @psalm-param array<string, mixed>|null $assoc
* @psalm-param AssociationMapping|null $assoc
*
* @return string[]
* @psalm-return list<string>
Expand Down Expand Up @@ -1767,7 +1769,7 @@ private function getSelectConditionStatementColumnSQL(
* Subclasses are supposed to override this method if they intend to change
* or alter the criteria by which entities are selected.
*
* @param mixed[]|null $assoc
* @param AssociationMapping|null $assoc
* @psalm-param array<string, mixed> $criteria
* @psalm-param array<string, mixed>|null $assoc
*
Expand Down Expand Up @@ -1810,7 +1812,7 @@ public function loadOneToManyCollection(array $assoc, $sourceEntity, PersistentC
* Builds criteria and execute SQL statement to fetch the one to many entities from.
*
* @param object $sourceEntity
* @psalm-param array<string, mixed> $assoc
* @psalm-param AssociationMapping $assoc
*/
private function getOneToManyStatement(
array $assoc,
Expand Down
Loading