diff --git a/docs/en/index.rst b/docs/en/index.rst index effca58d9a..f89c4ec269 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -72,6 +72,7 @@ Advanced Topics * :doc:`Transactions and Concurrency ` * :doc:`Filters ` * :doc:`NamingStrategy ` +* :doc:`TypedFieldMapper ` * :doc:`Improving Performance ` * :doc:`Caching ` * :doc:`Partial Objects ` diff --git a/docs/en/reference/basic-mapping.rst b/docs/en/reference/basic-mapping.rst index 1f59e60ddd..5475277d7d 100644 --- a/docs/en/reference/basic-mapping.rst +++ b/docs/en/reference/basic-mapping.rst @@ -289,6 +289,13 @@ These are the "automatic" mapping rules: As of version 2.11 Doctrine can also automatically map typed properties using a PHP 8.1 enum to set the right ``type`` and ``enumType``. +.. versionadded:: 2.14 + +Since version 2.14 you can specify custom typed field mapping between PHP type and DBAL type using ``Configuration`` +and a custom ``Doctrine\ORM\Mapping\TypedFieldMapper`` implementation. + +:doc:`Read more about TypedFieldMapper `. + .. _reference-mapping-types: Doctrine Mapping Types diff --git a/docs/en/reference/typedfieldmapper.rst b/docs/en/reference/typedfieldmapper.rst new file mode 100644 index 0000000000..1cc2518cdf --- /dev/null +++ b/docs/en/reference/typedfieldmapper.rst @@ -0,0 +1,176 @@ +Implementing a TypedFieldMapper +=============================== + +.. versionadded:: 2.14 + +You can specify custom typed field mapping between PHP type and DBAL type using ``Configuration`` +and a custom ``Doctrine\ORM\Mapping\TypedFieldMapper`` implementation. + +.. code-block:: php + + setTypedFieldMapper(new CustomTypedFieldMapper()); + + +DefaultTypedFieldMapper +----------------------- + +By default the ``Doctrine\ORM\Mapping\DefaultTypedFieldMapper`` is used, and you can pass an array of +PHP type => DBAL type mappings into its constructor to override the default behavior or add new mappings. + +.. code-block:: php + + setTypedFieldMapper(new DefaultTypedFieldMapper([ + CustomIdObject::class => CustomIdObjectType::class, + ])); + +Then, an entity using the ``CustomIdObject`` typed field will be correctly assigned its DBAL type +(``CustomIdObjectType``) without the need of explicit declaration. + +.. configuration-block:: + + .. code-block:: attribute + + + + + + + + + .. code-block:: yaml + + UserTypedWithCustomTypedField: + type: entity + fields: + customId: ~ + +It is perfectly valid to override even the "automatic" mapping rules mentioned above: + +.. code-block:: php + + setTypedFieldMapper(new DefaultTypedFieldMapper([ + 'int' => CustomIntType::class, + ])); + +.. note:: + + If chained, once the first ``TypedFieldMapper`` assigns a type to a field, the ``DefaultTypedFieldMapper`` will + ignore its mapping and not override it anymore (if it is later in the chain). See below for chaining type mappers. + + +TypedFieldMapper interface +------------------------- +The interface ``Doctrine\ORM\Mapping\TypedFieldMapper`` allows you to implement your own +typed field mapping logic. It consists of just one function + + +.. code-block:: php + + setTypedFieldMapper( + new ChainTypedFieldMapper( + DefaultTypedFieldMapper(['int' => CustomIntType::class,]), + new CustomTypedFieldMapper() + ) + ); + + +Implementing a TypedFieldMapper +------------------------------- + +If you want to assign all ``BackedEnum`` fields to your custom ``BackedEnumDBALType`` or you want to use different +DBAL types based on whether the entity field is nullable or not, you can achieve this by implementing your own +typed field mapper. + +You need to create a class which implements ``Doctrine\ORM\Mapping\TypedFieldMapper``. + +.. code-block:: php + + getType(); + + if ( + ! isset($mapping['type']) + && ($type instanceof ReflectionNamedType) + ) { + if (! $type->isBuiltin() && enum_exists($type->getName())) { + $mapping['type'] = BackedEnumDBALType::class; + } + } + + return $mapping; + } + } + +.. note:: + + Note that this case checks whether the mapping is already assigned, and if yes, it skips it. This is up to your + implementation. You can make a "greedy" mapper which will always override the mapping with its own type, or one + that behaves like ``DefaultTypedFieldMapper`` and does not modify the type once its set prior in the chain. \ No newline at end of file diff --git a/lib/Doctrine/ORM/Configuration.php b/lib/Doctrine/ORM/Configuration.php index 26e7f5d9df..e52d5d6741 100644 --- a/lib/Doctrine/ORM/Configuration.php +++ b/lib/Doctrine/ORM/Configuration.php @@ -35,6 +35,7 @@ use Doctrine\ORM\Mapping\EntityListenerResolver; use Doctrine\ORM\Mapping\NamingStrategy; use Doctrine\ORM\Mapping\QuoteStrategy; +use Doctrine\ORM\Mapping\TypedFieldMapper; use Doctrine\ORM\Proxy\ProxyFactory; use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\Filter\SQLFilter; @@ -719,7 +720,7 @@ public function addCustomDatetimeFunction($name, $className) * @param string $name * * @return string|callable|null - * @psalm-return class-string|callable|null $name + * @psalm-return class-string|callable|null */ public function getCustomDatetimeFunction($name) { @@ -748,6 +749,22 @@ public function setCustomDatetimeFunctions(array $functions) } } + /** + * Sets a TypedFieldMapper for php typed fields to DBAL types auto-completion. + */ + public function setTypedFieldMapper(?TypedFieldMapper $typedFieldMapper): void + { + $this->_attributes['typedFieldMapper'] = $typedFieldMapper; + } + + /** + * Gets a TypedFieldMapper for php typed fields to DBAL types auto-completion. + */ + public function getTypedFieldMapper(): ?TypedFieldMapper + { + return $this->_attributes['typedFieldMapper'] ?? null; + } + /** * Sets the custom hydrator modes in one pass. * diff --git a/lib/Doctrine/ORM/Mapping/ChainTypedFieldMapper.php b/lib/Doctrine/ORM/Mapping/ChainTypedFieldMapper.php new file mode 100644 index 0000000000..0a8544cec1 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ChainTypedFieldMapper.php @@ -0,0 +1,33 @@ +typedFieldMappers = $typedFieldMappers; + } + + /** + * {@inheritdoc} + */ + public function validateAndComplete(array $mapping, ReflectionProperty $field): array + { + foreach ($this->typedFieldMappers as $typedFieldMapper) { + $mapping = $typedFieldMapper->validateAndComplete($mapping, $field); + } + + return $mapping; + } +} diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadata.php b/lib/Doctrine/ORM/Mapping/ClassMetadata.php index c589fc205a..27980bc245 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadata.php @@ -21,8 +21,8 @@ class ClassMetadata extends ClassMetadataInfo * @param string $entityName The name of the entity class the new instance is used for. * @psalm-param class-string $entityName */ - public function __construct($entityName, ?NamingStrategy $namingStrategy = null) + public function __construct($entityName, ?NamingStrategy $namingStrategy = null, ?TypedFieldMapper $typedFieldMapper = null) { - parent::__construct($entityName, $namingStrategy); + parent::__construct($entityName, $namingStrategy, $typedFieldMapper); } } diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php index d52b97ea3b..cc0fcd8c61 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php @@ -293,7 +293,11 @@ protected function validateRuntimeMetadata($class, $parent) */ protected function newClassMetadataInstance($className) { - return new ClassMetadata($className, $this->em->getConfiguration()->getNamingStrategy()); + return new ClassMetadata( + $className, + $this->em->getConfiguration()->getNamingStrategy(), + $this->em->getConfiguration()->getTypedFieldMapper() + ); } /** diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index b207d92266..d58211d620 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -6,12 +6,8 @@ use BackedEnum; use BadMethodCallException; -use DateInterval; -use DateTime; -use DateTimeImmutable; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; -use Doctrine\DBAL\Types\Types; use Doctrine\Deprecations\Deprecation; use Doctrine\Instantiator\Instantiator; use Doctrine\Instantiator\InstantiatorInterface; @@ -23,7 +19,6 @@ use InvalidArgumentException; use LogicException; use ReflectionClass; -use ReflectionEnum; use ReflectionNamedType; use ReflectionProperty; use RuntimeException; @@ -800,6 +795,9 @@ class ClassMetadataInfo implements ClassMetadata /** @var InstantiatorInterface|null */ private $instantiator; + /** @var TypedFieldMapper $typedFieldMapper */ + private $typedFieldMapper; + /** * Initializes a new ClassMetadata instance that will hold the object-relational mapping * metadata of the class with the given name. @@ -807,12 +805,13 @@ class ClassMetadataInfo implements ClassMetadata * @param string $entityName The name of the entity class the new instance is used for. * @psalm-param class-string $entityName */ - public function __construct($entityName, ?NamingStrategy $namingStrategy = null) + public function __construct($entityName, ?NamingStrategy $namingStrategy = null, ?TypedFieldMapper $typedFieldMapper = null) { - $this->name = $entityName; - $this->rootEntityName = $entityName; - $this->namingStrategy = $namingStrategy ?: new DefaultNamingStrategy(); - $this->instantiator = new Instantiator(); + $this->name = $entityName; + $this->rootEntityName = $entityName; + $this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy(); + $this->instantiator = new Instantiator(); + $this->typedFieldMapper = $typedFieldMapper ?? new DefaultTypedFieldMapper(); } /** @@ -1580,50 +1579,9 @@ private function isTypedProperty(string $name): bool */ private function validateAndCompleteTypedFieldMapping(array $mapping): array { - $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); - - if ($type) { - if ( - ! isset($mapping['type']) - && ($type instanceof ReflectionNamedType) - ) { - if (PHP_VERSION_ID >= 80100 && ! $type->isBuiltin() && enum_exists($type->getName())) { - $mapping['enumType'] = $type->getName(); - - $reflection = new ReflectionEnum($type->getName()); - $type = $reflection->getBackingType(); + $field = $this->reflClass->getProperty($mapping['fieldName']); - assert($type instanceof ReflectionNamedType); - } - - switch ($type->getName()) { - case DateInterval::class: - $mapping['type'] = Types::DATEINTERVAL; - break; - case DateTime::class: - $mapping['type'] = Types::DATETIME_MUTABLE; - break; - case DateTimeImmutable::class: - $mapping['type'] = Types::DATETIME_IMMUTABLE; - break; - case 'array': - $mapping['type'] = Types::JSON; - break; - case 'bool': - $mapping['type'] = Types::BOOLEAN; - break; - case 'float': - $mapping['type'] = Types::FLOAT; - break; - case 'int': - $mapping['type'] = Types::INTEGER; - break; - case 'string': - $mapping['type'] = Types::STRING; - break; - } - } - } + $mapping = $this->typedFieldMapper->validateAndComplete($mapping, $field); return $mapping; } diff --git a/lib/Doctrine/ORM/Mapping/DefaultTypedFieldMapper.php b/lib/Doctrine/ORM/Mapping/DefaultTypedFieldMapper.php new file mode 100644 index 0000000000..728482cfa6 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/DefaultTypedFieldMapper.php @@ -0,0 +1,72 @@ +|string> $typedFieldMappings */ + private $typedFieldMappings; + + private const DEFAULT_TYPED_FIELD_MAPPINGS = [ + DateInterval::class => Types::DATEINTERVAL, + DateTime::class => Types::DATETIME_MUTABLE, + DateTimeImmutable::class => Types::DATETIME_IMMUTABLE, + 'array' => Types::JSON, + 'bool' => Types::BOOLEAN, + 'float' => Types::FLOAT, + 'int' => Types::INTEGER, + 'string' => Types::STRING, + ]; + + /** @param array|string> $typedFieldMappings */ + public function __construct(array $typedFieldMappings = []) + { + $this->typedFieldMappings = array_merge(self::DEFAULT_TYPED_FIELD_MAPPINGS, $typedFieldMappings); + } + + /** + * {@inheritdoc} + */ + public function validateAndComplete(array $mapping, ReflectionProperty $field): array + { + $type = $field->getType(); + + if ( + ! isset($mapping['type']) + && ($type instanceof ReflectionNamedType) + ) { + if (PHP_VERSION_ID >= 80100 && ! $type->isBuiltin() && enum_exists($type->getName())) { + $mapping['enumType'] = $type->getName(); + + $reflection = new ReflectionEnum($type->getName()); + $type = $reflection->getBackingType(); + + assert($type instanceof ReflectionNamedType); + } + + if (isset($this->typedFieldMappings[$type->getName()])) { + $mapping['type'] = $this->typedFieldMappings[$type->getName()]; + } + } + + return $mapping; + } +} diff --git a/lib/Doctrine/ORM/Mapping/TypedFieldMapper.php b/lib/Doctrine/ORM/Mapping/TypedFieldMapper.php new file mode 100644 index 0000000000..faf84dbd75 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/TypedFieldMapper.php @@ -0,0 +1,19 @@ +strrpos($className, '\\') + + + array_merge(self::DEFAULT_TYPED_FIELD_MAPPINGS, $typedFieldMappings) + + canEmulateSchemas diff --git a/tests/Doctrine/Tests/DbalTypes/CustomIntType.php b/tests/Doctrine/Tests/DbalTypes/CustomIntType.php new file mode 100644 index 0000000000..3a0e613620 --- /dev/null +++ b/tests/Doctrine/Tests/DbalTypes/CustomIntType.php @@ -0,0 +1,20 @@ +setInheritanceType(ClassMetadata::INHERITANCE_TYPE_NONE); + $metadata->setPrimaryTable( + ['name' => 'cms_users_typed_with_custom_typed_field'] + ); + + $metadata->mapField( + [ + 'id' => true, + 'fieldName' => 'id', + ] + ); + $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + + $metadata->mapField( + ['fieldName' => 'customId'] + ); + + $metadata->mapField( + ['fieldName' => 'customIntTypedField'] + ); + } +} diff --git a/tests/Doctrine/Tests/ORM/ConfigurationTest.php b/tests/Doctrine/Tests/ORM/ConfigurationTest.php index d8007e70cc..e75721b9cd 100644 --- a/tests/Doctrine/Tests/ORM/ConfigurationTest.php +++ b/tests/Doctrine/Tests/ORM/ConfigurationTest.php @@ -19,6 +19,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Exception\ProxyClassesAlwaysRegenerating; use Doctrine\ORM\Mapping as AnnotationNamespace; +use Doctrine\ORM\Mapping\DefaultTypedFieldMapper; use Doctrine\ORM\Mapping\EntityListenerResolver; use Doctrine\ORM\Mapping\NamingStrategy; use Doctrine\ORM\Mapping\PrePersist; @@ -440,6 +441,15 @@ public function testSetGetSecondLevelCacheConfig(): void $this->configuration->setSecondLevelCacheConfiguration($mockClass); self::assertEquals($mockClass, $this->configuration->getSecondLevelCacheConfiguration()); } + + /** @group GH10313 */ + public function testSetGetTypedFieldMapper(): void + { + self::assertEmpty($this->configuration->getTypedFieldMapper()); + $defaultTypedFieldMapper = new DefaultTypedFieldMapper(); + $this->configuration->setTypedFieldMapper($defaultTypedFieldMapper); + self::assertSame($defaultTypedFieldMapper, $this->configuration->getTypedFieldMapper()); + } } class ConfigurationTestAnnotationReaderChecker diff --git a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php index f901be8c6c..625a47405f 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php @@ -5,15 +5,21 @@ namespace Doctrine\Tests\ORM\Mapping; use ArrayObject; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Events; +use Doctrine\ORM\Mapping\ChainTypedFieldMapper; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\DefaultNamingStrategy; +use Doctrine\ORM\Mapping\DefaultTypedFieldMapper; use Doctrine\ORM\Mapping\MappedSuperclass; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\UnderscoreNamingStrategy; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Doctrine\Persistence\Mapping\StaticReflectionService; +use Doctrine\Tests\DbalTypes\CustomIdObject; +use Doctrine\Tests\DbalTypes\CustomIdObjectType; +use Doctrine\Tests\DbalTypes\CustomIntType; use Doctrine\Tests\Models\CMS; use Doctrine\Tests\Models\CMS\CmsEmail; use Doctrine\Tests\Models\Company\CompanyContract; @@ -25,6 +31,7 @@ use Doctrine\Tests\Models\DDC964\DDC964Guest; use Doctrine\Tests\Models\Routing\RoutingLeg; use Doctrine\Tests\Models\TypedProperties; +use Doctrine\Tests\ORM\Mapping\TypedFieldMapper\CustomIntAsStringTypedFieldMapper; use Doctrine\Tests\OrmTestCase; use DoctrineGlobalArticle; use ReflectionClass; @@ -177,6 +184,52 @@ public function testFieldTypeFromReflection(): void self::assertEquals('float', $cm->getTypeOfField('float')); } + /** + * @group GH10313 + * @requires PHP 7.4 + */ + public function testFieldTypeFromReflectionDefaultTypedFieldMapper(): void + { + $cm = new ClassMetadata( + TypedProperties\UserTypedWithCustomTypedField::class, + null, + new DefaultTypedFieldMapper( + [ + CustomIdObject::class => CustomIdObjectType::class, + 'int' => CustomIntType::class, + ] + ) + ); + $cm->initializeReflection(new RuntimeReflectionService()); + + $cm->mapField(['fieldName' => 'customId']); + $cm->mapField(['fieldName' => 'customIntTypedField']); + self::assertEquals(CustomIdObjectType::class, $cm->getTypeOfField('customId')); + self::assertEquals(CustomIntType::class, $cm->getTypeOfField('customIntTypedField')); + } + + /** + * @group GH10313 + * @requires PHP 7.4 + */ + public function testFieldTypeFromReflectionChainTypedFieldMapper(): void + { + $cm = new ClassMetadata( + TypedProperties\UserTyped::class, + null, + new ChainTypedFieldMapper( + new CustomIntAsStringTypedFieldMapper(), + new DefaultTypedFieldMapper() + ) + ); + $cm->initializeReflection(new RuntimeReflectionService()); + + $cm->mapField(['fieldName' => 'id']); + $cm->mapField(['fieldName' => 'username']); + self::assertEquals(Types::STRING, $cm->getTypeOfField('id')); + self::assertEquals(Types::STRING, $cm->getTypeOfField('username')); + } + /** @group DDC-115 */ public function testMapAssociationInGlobalNamespace(): void { diff --git a/tests/Doctrine/Tests/ORM/Mapping/MappingDriverTestCase.php b/tests/Doctrine/Tests/ORM/Mapping/MappingDriverTestCase.php index f9d144f34e..316bd8a6dc 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/MappingDriverTestCase.php +++ b/tests/Doctrine/Tests/ORM/Mapping/MappingDriverTestCase.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\CustomIdGenerator; use Doctrine\ORM\Mapping\DefaultNamingStrategy; +use Doctrine\ORM\Mapping\DefaultTypedFieldMapper; use Doctrine\ORM\Mapping\DiscriminatorColumn; use Doctrine\ORM\Mapping\DiscriminatorMap; use Doctrine\ORM\Mapping\Entity; @@ -27,6 +28,7 @@ use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\NamedQueries; use Doctrine\ORM\Mapping\NamedQuery; +use Doctrine\ORM\Mapping\NamingStrategy; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OrderBy; @@ -34,11 +36,15 @@ use Doctrine\ORM\Mapping\PrePersist; use Doctrine\ORM\Mapping\SequenceGenerator; use Doctrine\ORM\Mapping\Table; +use Doctrine\ORM\Mapping\TypedFieldMapper; use Doctrine\ORM\Mapping\UnderscoreNamingStrategy; use Doctrine\ORM\Mapping\UniqueConstraint; use Doctrine\ORM\Mapping\Version; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\Mapping\RuntimeReflectionService; +use Doctrine\Tests\DbalTypes\CustomIdObject; +use Doctrine\Tests\DbalTypes\CustomIdObjectType; +use Doctrine\Tests\DbalTypes\CustomIntType; use Doctrine\Tests\Models\Cache\City; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsAddressListener; @@ -68,6 +74,7 @@ use Doctrine\Tests\Models\GH10288\GH10288People; use Doctrine\Tests\Models\TypedProperties\Contact; use Doctrine\Tests\Models\TypedProperties\UserTyped; +use Doctrine\Tests\Models\TypedProperties\UserTypedWithCustomTypedField; use Doctrine\Tests\Models\Upsertable\Insertable; use Doctrine\Tests\Models\Upsertable\Updatable; use Doctrine\Tests\OrmTestCase; @@ -86,12 +93,17 @@ abstract class MappingDriverTestCase extends OrmTestCase { abstract protected function loadDriver(): MappingDriver; - /** @psalm-param class-string $entityClassName */ - public function createClassMetadata(string $entityClassName): ClassMetadata - { + /** + * @psalm-param class-string $entityClassName + */ + public function createClassMetadata( + string $entityClassName, + ?NamingStrategy $namingStrategy = null, + ?TypedFieldMapper $typedFieldMapper = null + ): ClassMetadata { $mappingDriver = $this->loadDriver(); - $class = new ClassMetadata($entityClassName); + $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper); $class->initializeReflection(new RuntimeReflectionService()); $mappingDriver->loadMetadataForClass($entityClassName, $class); @@ -271,12 +283,11 @@ public function testStringFieldMappings(ClassMetadata $class): ClassMetadata return $class; } + /** + * @requires PHP 7.4 + */ public function testFieldTypeFromReflection(): void { - if (PHP_VERSION_ID < 70400) { - self::markTestSkipped('requies PHP 7.4'); - } - $class = $this->createClassMetadata(UserTyped::class); self::assertEquals('integer', $class->getTypeOfField('id')); @@ -293,6 +304,27 @@ public function testFieldTypeFromReflection(): void self::assertEquals(Contact::class, $class->embeddedClasses['contact']['class']); } + /** + * @group GH10313 + * @requires PHP 7.4 + */ + public function testCustomFieldTypeFromReflection(): void + { + $class = $this->createClassMetadata( + UserTypedWithCustomTypedField::class, + null, + new DefaultTypedFieldMapper( + [ + CustomIdObject::class => CustomIdObjectType::class, + 'int' => CustomIntType::class, + ] + ) + ); + + self::assertEquals(CustomIdObjectType::class, $class->getTypeOfField('customId')); + self::assertEquals(CustomIntType::class, $class->getTypeOfField('customIntTypedField')); + } + /** @depends testEntityTableNameAndInheritance */ public function testFieldOptions(ClassMetadata $class): ClassMetadata { diff --git a/tests/Doctrine/Tests/ORM/Mapping/TypedFieldMapper/CustomIntAsStringTypedFieldMapper.php b/tests/Doctrine/Tests/ORM/Mapping/TypedFieldMapper/CustomIntAsStringTypedFieldMapper.php new file mode 100644 index 0000000000..31a1edd66d --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/TypedFieldMapper/CustomIntAsStringTypedFieldMapper.php @@ -0,0 +1,32 @@ +getType(); + + if ( + ! isset($mapping['type']) + && ($type instanceof ReflectionNamedType) + ) { + if ($type->getName() === 'int') { + $mapping['type'] = Types::STRING; + } + } + + return $mapping; + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/TypedFieldMapperTest.php b/tests/Doctrine/Tests/ORM/Mapping/TypedFieldMapperTest.php new file mode 100644 index 0000000000..8960a530d4 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/TypedFieldMapperTest.php @@ -0,0 +1,86 @@ + + */ + public static function dataFieldToMappedField(): array + { + $reflectionClass = new ReflectionClass(UserTyped::class); + + return [ + // DefaultTypedFieldMapper + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'id'], ['fieldName' => 'id', 'type' => Types::INTEGER]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'username'], ['fieldName' => 'username', 'type' => Types::STRING]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'dateInterval'], ['fieldName' => 'dateInterval', 'type' => Types::DATEINTERVAL]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'dateTime'], ['fieldName' => 'dateTime', 'type' => Types::DATETIME_MUTABLE]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'dateTimeImmutable'], ['fieldName' => 'dateTimeImmutable', 'type' => Types::DATETIME_IMMUTABLE]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'array'], ['fieldName' => 'array', 'type' => Types::JSON]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'boolean'], ['fieldName' => 'boolean', 'type' => Types::BOOLEAN]], + [self::defaultTypedFieldMapper(), $reflectionClass, ['fieldName' => 'float'], ['fieldName' => 'float', 'type' => Types::FLOAT]], + + // CustomIntAsStringTypedFieldMapper + [self::customTypedFieldMapper(), $reflectionClass, ['fieldName' => 'id'], ['fieldName' => 'id', 'type' => Types::STRING]], + + // ChainTypedFieldMapper + [self::chainTypedFieldMapper(), $reflectionClass, ['fieldName' => 'id'], ['fieldName' => 'id', 'type' => Types::STRING]], + [self::chainTypedFieldMapper(), $reflectionClass, ['fieldName' => 'username'], ['fieldName' => 'username', 'type' => Types::STRING]], + ]; + } + + /** + * @param array{fieldName: string, enumType?: string, type?: mixed} $mapping + * @param array{fieldName: string, enumType?: string, type?: mixed} $finalMapping + * + * @dataProvider dataFieldToMappedField + */ + public function testValidateAndComplete( + TypedFieldMapper $typedFieldMapper, + ReflectionClass $reflectionClass, + array $mapping, + array $finalMapping + ): void { + self::assertSame($finalMapping, $typedFieldMapper->validateAndComplete($mapping, $reflectionClass->getProperty($mapping['fieldName']))); + } +} diff --git a/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.php b/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.php new file mode 100644 index 0000000000..91e07b82da --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.php @@ -0,0 +1,27 @@ +setInheritanceType(ClassMetadata::INHERITANCE_TYPE_NONE); +$metadata->setPrimaryTable( + ['name' => 'cms_users_typed_with_custom_typed_field'] +); + +$metadata->mapField( + [ + 'id' => true, + 'fieldName' => 'id', + ] +); + +$metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + +$metadata->mapField( + ['fieldName' => 'customId'] +); + +$metadata->mapField( + ['fieldName' => 'customIntTypedField'] +); diff --git a/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.dcm.xml b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.dcm.xml new file mode 100644 index 0000000000..ee93fa96f8 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.dcm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.dcm.yml b/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.dcm.yml new file mode 100644 index 0000000000..711d5de447 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.TypedProperties.UserTypedWithCustomTypedField.dcm.yml @@ -0,0 +1,10 @@ +Doctrine\Tests\Models\TypedProperties\UserTypedWithCustomTypedField: + type: entity + table: cms_users_typed_with_custom_typed_field + id: + id: + generator: + strategy: AUTO + fields: + customId: ~ + customIntTypedField: ~