diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadata.php b/lib/Doctrine/ORM/Mapping/ClassMetadata.php index afda64f79a7..0f07031c7c1 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadata.php @@ -40,6 +40,7 @@ use function in_array; use function interface_exists; use function is_array; +use function is_string; use function is_subclass_of; use function ltrim; use function method_exists; @@ -66,32 +67,6 @@ * * @template-covariant T of object * @template-implements PersistenceClassMetadata - * @psalm-type FieldMapping = array{ - * type: string, - * fieldName: string, - * columnName: string, - * length?: int, - * id?: bool, - * nullable?: bool, - * notInsertable?: bool, - * notUpdatable?: bool, - * generated?: int, - * enumType?: class-string, - * columnDefinition?: string, - * precision?: int, - * scale?: int, - * unique?: string, - * inherited?: class-string, - * originalClass?: class-string, - * originalField?: string, - * quoted?: bool, - * requireSQLConversion?: bool, - * declared?: class-string, - * declaredField?: string, - * options?: array, - * version?: string, - * default?: string|int, - * } * @psalm-type JoinColumnData = array{ * name: string, * referencedColumnName: string, @@ -412,65 +387,9 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable /** * READ-ONLY: The field mappings of the class. - * Keys are field names and values are mapping definitions. - * - * The mapping definition array has the following values: - * - * - fieldName (string) - * The name of the field in the Entity. - * - * - type (string) - * The type name of the mapped field. Can be one of Doctrine's mapping types - * or a custom mapping type. - * - * - columnName (string, optional) - * The column name. Optional. Defaults to the field name. - * - * - length (integer, optional) - * The database length of the column. Optional. Default value taken from - * the type. - * - * - id (boolean, optional) - * Marks the field as the primary key of the entity. Multiple fields of an - * entity can have the id attribute, forming a composite key. + * Keys are field names and values are FieldMapping instances * - * - nullable (boolean, optional) - * Whether the column is nullable. Defaults to FALSE. - * - * - 'notInsertable' (boolean, optional) - * Whether the column is not insertable. Optional. Is only set if value is TRUE. - * - * - 'notUpdatable' (boolean, optional) - * Whether the column is updatable. Optional. Is only set if value is TRUE. - * - * - columnDefinition (string, optional, schema-only) - * The SQL fragment that is used when generating the DDL for the column. - * - * - precision (integer, optional, schema-only) - * The precision of a decimal column. Only valid if the column type is decimal. - * - * - scale (integer, optional, schema-only) - * The scale of a decimal column. Only valid if the column type is decimal. - * - * - 'unique' (string, optional, schema-only) - * Whether a unique constraint should be generated for the column. - * - * - 'inherited' (string, optional) - * This is set when the field is inherited by this class from another (inheritance) parent - * entity class. The value is the FQCN of the topmost entity class that contains - * mapping information for this field. (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.) - * Fields initially declared in mapped superclasses are - * not considered 'inherited' in the nearest entity subclasses. - * - * - 'declared' (string, optional) - * This is set when the field does not appear for the first time in this class, but is originally - * declared in another parent entity or mapped superclass. The value is the FQCN - * of the topmost non-transient class that contains mapping information for this field. - * - * @var mixed[] - * @psalm-var array + * @var array */ public array $fieldMappings = []; @@ -1252,12 +1171,9 @@ public function getColumnName(string $fieldName): string * Gets the mapping of a (regular) field that holds some data but not a * reference to another object. * - * @return mixed[] The field mapping. - * @psalm-return FieldMapping - * * @throws MappingException */ - public function getFieldMapping(string $fieldName): array + public function getFieldMapping(string $fieldName): FieldMapping { if (! isset($this->fieldMappings[$fieldName])) { throw MappingException::mappingNotFound($this->name, $fieldName); @@ -1372,7 +1288,7 @@ private function validateAndCompleteTypedAssociationMapping(array $mapping): arr * * @throws MappingException */ - protected function validateAndCompleteFieldMapping(array $mapping): array + protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping { // Check mandatory fields if (! isset($mapping['fieldName']) || ! $mapping['fieldName']) { @@ -1393,6 +1309,8 @@ protected function validateAndCompleteFieldMapping(array $mapping): array $mapping['columnName'] = $this->namingStrategy->propertyToColumnName($mapping['fieldName'], $this->name); } + $mapping = FieldMapping::fromMappingArray($mapping); + if ($mapping['columnName'][0] === '`') { $mapping['columnName'] = trim($mapping['columnName'], '`'); $mapping['quoted'] = true; @@ -1522,6 +1440,7 @@ protected function _validateAndCompleteAssociationMapping(array $mapping) ); } + assert(is_string($mapping['fieldName'])); $this->identifier[] = $mapping['fieldName']; $this->containsForeignIdentifier = true; } @@ -2388,10 +2307,8 @@ public function addInheritedAssociationMapping(array $mapping/*, $owningClassNam * INTERNAL: * Adds a field mapping without completing/validating it. * This is mainly used to add inherited field mappings to derived classes. - * - * @psalm-param array $fieldMapping */ - public function addInheritedFieldMapping(array $fieldMapping): void + public function addInheritedFieldMapping(FieldMapping $fieldMapping): void { $this->fieldMappings[$fieldMapping['fieldName']] = $fieldMapping; $this->columnNames[$fieldMapping['fieldName']] = $fieldMapping['columnName']; @@ -2968,7 +2885,8 @@ public function mapEmbedded(array $mapping): void */ public function inlineEmbeddable(string $property, ClassMetadata $embeddable): void { - foreach ($embeddable->fieldMappings as $fieldMapping) { + foreach ($embeddable->fieldMappings as $originalFieldMapping) { + $fieldMapping = (array) $originalFieldMapping; $fieldMapping['originalClass'] ??= $embeddable->name; $fieldMapping['declaredField'] = isset($fieldMapping['declaredField']) ? $property . '.' . $fieldMapping['declaredField'] diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php index a33ebd6f9b9..8703adaca5a 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php @@ -326,9 +326,9 @@ private function getShortName(string $className): string * Puts the `inherited` and `declared` values into mapping information for fields, associations * and embedded classes. * - * @param mixed[] $mapping + * @param mixed[]|FieldMapping $mapping */ - private function addMappingInheritanceInformation(array &$mapping, ClassMetadata $parentClass): void + private function addMappingInheritanceInformation(array|FieldMapping &$mapping, ClassMetadata $parentClass): void { if (! isset($mapping['inherited']) && ! $parentClass->isMappedSuperclass) { $mapping['inherited'] = $parentClass->name; @@ -345,8 +345,9 @@ private function addMappingInheritanceInformation(array &$mapping, ClassMetadata private function addInheritedFields(ClassMetadata $subClass, ClassMetadata $parentClass): void { foreach ($parentClass->fieldMappings as $mapping) { - $this->addMappingInheritanceInformation($mapping, $parentClass); - $subClass->addInheritedFieldMapping($mapping); + $subClassMapping = clone $mapping; + $this->addMappingInheritanceInformation($subClassMapping, $parentClass); + $subClass->addInheritedFieldMapping($subClassMapping); } foreach ($parentClass->reflFields as $name => $field) { diff --git a/lib/Doctrine/ORM/Mapping/FieldMapping.php b/lib/Doctrine/ORM/Mapping/FieldMapping.php new file mode 100644 index 00000000000..0b0d6967fa0 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/FieldMapping.php @@ -0,0 +1,132 @@ + */ +final class FieldMapping implements ArrayAccess +{ + /** @var int|null The database length of the column. Optional. Default value taken from the type. */ + public int|null $length = null; + /** + * @var bool|null Marks the field as the primary key of the entity. Multiple + * fields of an entity can have the id attribute, forming a composite key. + */ + public bool|null $id = null; + public bool|null $nullable = null; + public bool|null $notInsertable = null; + public bool|null $notUpdatable = null; + public string|null $columnDefinition = null; + /** ClassMetadata::GENERATED_* */ + public int|null $generated = null; + /** @var class-string|null */ + public string|null $enumType = null; + /** + * @var int|null The precision of a decimal column. + * Only valid if the column type is decimal + */ + public int|null $precision = null; + /** + * @var int|null The scale of a decimal column. + * Only valid if the column type is decimal + */ + public int|null $scale = null; + /** @var bool|null Whether a unique constraint should be generated for the column. */ + public bool|null $unique = null; + /** + * @var class-string|null This is set when the field is inherited by this + * class from another (inheritance) parent entity class. The value + * is the FQCN of the topmost entity class that contains mapping information + * for this field. (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.) + * Fields initially declared in mapped superclasses are + * not considered 'inherited' in the nearest entity subclasses. + */ + public string|null $inherited = null; + + public string|null $originalClass = null; + public string|null $originalField = null; + public bool|null $quoted = null; + /** + * @var class-string|null This is set when the field does not appear for + * the first time in this class, but is originally declared in another + * parent entity or mapped superclass. The value is the FQCN of + * the topmost non-transient class that contains mapping information for + * this field. + */ + public string|null $declared = null; + public string|null $declaredField = null; + public array|null $options = null; + public bool|null $version = null; + public string|int|null $default = null; + + /** + * @param string $type The type name of the mapped field. Can be one of + * Doctrine's mapping types or a custom mapping type. + * @param string $fieldName The name of the field in the Entity. + * @param string $columnName The column name. Optional. Defaults to the field name. + */ + public function __construct( + public string $type, + public string $fieldName, + public string $columnName, + ) { + } + + /** @param array{type: string, fieldName: string, columnName: string} $mappingArray */ + public static function fromMappingArray(array $mappingArray): self + { + $mapping = new self( + $mappingArray['type'], + $mappingArray['fieldName'], + $mappingArray['columnName'], + ); + foreach ($mappingArray as $key => $value) { + if (in_array($key, ['type', 'fieldName', 'columnName'])) { + continue; + } + + if (property_exists($mapping, $key)) { + $mapping->$key = $value; + } + } + + return $mapping; + } + + /** + * {@inheritDoc} + */ + public function offsetExists($offset): bool + { + return isset($this->$offset); + } + + public function offsetGet($offset): mixed + { + return $this->$offset; + } + + /** + * {@inheritDoc} + */ + public function offsetSet($offset, $value): void + { + $this->$offset = $value; + } + + /** + * {@inheritDoc} + */ + public function offsetUnset($offset): void + { + $this->$offset = null; + } +} diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index b61880a0076..3171d43ea17 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -1845,7 +1845,7 @@ private function getTypes(string $field, mixed $value, ClassMetadata $class): ar switch (true) { case isset($class->fieldMappings[$field]): - $types = array_merge($types, [$class->fieldMappings[$field]['type']]); + $types = array_merge($types, [$class->fieldMappings[$field]->type]); break; case isset($class->associationMappings[$field]): diff --git a/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php b/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php index 3afe57997d8..1e4c19ccba9 100644 --- a/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php +++ b/lib/Doctrine/ORM/Tools/Console/Command/MappingDescribeCommand.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\Persistence\Mapping\MappingException; use InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; @@ -242,7 +243,7 @@ private function formatField(string $label, mixed $value): array /** * Format the association mappings * - * @psalm-param array> $propertyMappings + * @psalm-param array> $propertyMappings * * @return string[][] * @psalm-return list @@ -254,7 +255,7 @@ private function formatMappings(array $propertyMappings): array foreach ($propertyMappings as $propertyName => $mapping) { $output[] = $this->formatField(sprintf(' %s', $propertyName), ''); - foreach ($mapping as $field => $value) { + foreach ((array) $mapping as $field => $value) { $output[] = $this->formatField(sprintf(' %s', $field), $this->formatValue($value)); } } diff --git a/lib/Doctrine/ORM/Tools/SchemaTool.php b/lib/Doctrine/ORM/Tools/SchemaTool.php index ceec19a6db2..1b8136b4a00 100644 --- a/lib/Doctrine/ORM/Tools/SchemaTool.php +++ b/lib/Doctrine/ORM/Tools/SchemaTool.php @@ -15,6 +15,7 @@ use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\QuoteStrategy; use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; @@ -447,11 +448,11 @@ private function gatherColumns(ClassMetadata $class, Table $table): void * Creates a column definition as required by the DBAL from an ORM field mapping definition. * * @param ClassMetadata $class The class that owns the field mapping. - * @psalm-param array $mapping The field mapping. + * @psalm-param FieldMapping $mapping The field mapping. */ private function gatherColumn( ClassMetadata $class, - array $mapping, + FieldMapping $mapping, Table $table, ): void { $columnName = $this->quoteStrategy->getColumnName($mapping['fieldName'], $class, $this->platform); @@ -766,11 +767,11 @@ private function gatherRelationJoinColumns( } /** - * @param mixed[] $mapping + * @param FieldMapping|mixed[] $mapping * * @return mixed[] */ - private function gatherColumnOptions(array $mapping): array + private function gatherColumnOptions(FieldMapping|array $mapping): array { $mappingOptions = $mapping['options'] ?? []; diff --git a/lib/Doctrine/ORM/Utility/PersisterHelper.php b/lib/Doctrine/ORM/Utility/PersisterHelper.php index 9eca8aa6613..eff5fd2daea 100644 --- a/lib/Doctrine/ORM/Utility/PersisterHelper.php +++ b/lib/Doctrine/ORM/Utility/PersisterHelper.php @@ -27,7 +27,7 @@ class PersisterHelper public static function getTypeOfField(string $fieldName, ClassMetadata $class, EntityManagerInterface $em): array { if (isset($class->fieldMappings[$fieldName])) { - return [$class->fieldMappings[$fieldName]['type']]; + return [$class->fieldMappings[$fieldName]->type]; } if (! isset($class->associationMappings[$fieldName])) { diff --git a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php index d55e3827044..30c8a43f9f8 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataBuilderTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Mapping\Builder\EmbeddedBuilder; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\ORM\Mapping\MappingException; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Doctrine\Tests\Models\CMS\CmsGroup; @@ -211,7 +212,10 @@ public function testChangeTrackingPolicyNotify(): void public function testAddField(): void { $this->assertIsFluent($this->builder->addField('name', 'string')); - self::assertEquals(['columnName' => 'name', 'fieldName' => 'name', 'type' => 'string'], $this->cm->fieldMappings['name']); + $mapping = $this->cm->getFieldMapping('name'); + self::assertSame('name', $mapping['fieldName']); + self::assertSame('name', $mapping['columnName']); + self::assertSame('string', $mapping['type']); } public function testCreateField(): void @@ -221,14 +225,17 @@ public function testCreateField(): void self::assertFalse(isset($this->cm->fieldMappings['name'])); $this->assertIsFluent($fieldBuilder->build()); - self::assertEquals(['columnName' => 'name', 'fieldName' => 'name', 'type' => 'string'], $this->cm->fieldMappings['name']); + $mapping = $this->cm->getFieldMapping('name'); + self::assertSame('name', $mapping['fieldName']); + self::assertSame('name', $mapping['columnName']); + self::assertSame('string', $mapping['type']); } public function testCreateVersionedField(): void { $this->builder->createField('name', 'integer')->columnName('username')->length(124)->nullable()->columnDefinition('foobar')->unique()->isVersionField()->build(); self::assertEquals( - [ + FieldMapping::fromMappingArray([ 'columnDefinition' => 'foobar', 'columnName' => 'username', 'default' => 1, @@ -237,7 +244,7 @@ public function testCreateVersionedField(): void 'type' => 'integer', 'nullable' => true, 'unique' => true, - ], + ]), $this->cm->fieldMappings['name'], ); } @@ -247,17 +254,18 @@ public function testCreatePrimaryField(): void $this->builder->createField('id', 'integer')->makePrimaryKey()->generatedValue()->build(); self::assertEquals(['id'], $this->cm->identifier); - self::assertEquals(['columnName' => 'id', 'fieldName' => 'id', 'id' => true, 'type' => 'integer'], $this->cm->fieldMappings['id']); + self::assertEquals(FieldMapping::fromMappingArray( + ['columnName' => 'id', 'fieldName' => 'id', 'id' => true, 'type' => 'integer'], + ), $this->cm->fieldMappings['id']); } public function testCreateUnsignedOptionField(): void { $this->builder->createField('state', 'integer')->option('unsigned', true)->build(); - self::assertEquals( + self::assertEquals(FieldMapping::fromMappingArray( ['fieldName' => 'state', 'type' => 'integer', 'options' => ['unsigned' => true], 'columnName' => 'state'], - $this->cm->fieldMappings['state'], - ); + ), $this->cm->fieldMappings['state']); } public function testAddLifecycleEvent(): void