Skip to content

Commit

Permalink
Add TypedFieldMapper for automatic mapping of typed PHP fields to DBA…
Browse files Browse the repository at this point in the history
…L types

Previously, only a predefined set of automatic mappings was allowed, such as int, float, boolean, DateTime etc.
With this extension, it is possible to supply custom TypedFieldMapper implementation which takes as parameter the ReflectionProperty of a given field and decides the appropriate mapping.
A new configuration option was added to set and get the TypedFieldMapper.
The old logic was moved into a class DefaultTypedFieldMapper which is used by default when no mapper is supplied.
The selected TypedFieldMapper is passed into ClassMetadataInfo constructor. If empty, the DefaultTypedFieldMapper is used.
There is also ChainTypedFieldMapper class which allows chaining multiple TypedFieldMappers and apply them in a cascade (always if a field gets type assigned by the earlier mapper in the list, it will not be changed later).
  • Loading branch information
michnovka committed Dec 16, 2022
1 parent 13bab31 commit 657f376
Show file tree
Hide file tree
Showing 17 changed files with 564 additions and 62 deletions.
85 changes: 85 additions & 0 deletions docs/en/reference/basic-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,91 @@ 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.

.. code-block:: php
<?php
$configuration->setTypedFieldMapper(new CustomTypedFieldMapper());
by default the ``Doctrine\ORM\Mapping\DefaultTypedFieldMapper`` is used, but it can be further extended to allow
overriding PHP type => DBAL type mappings for specific cases:

.. code-block:: php
<?php
use App\CustomIds\CustomIdObject;
use App\DBAL\Type\CustomIdObjectType;
$configuration->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
<?php
#[ORM\Entity]
#[ORM\Table(name: 'cms_users_typed_with_custom_typed_field')]
class UserTypedWithCustomTypedField
{
#[ORM\Column]
public CustomIdObject $customId;
// ...
}
.. code-block:: annotation
<?php
/**
* @Entity
* @Table(name="cms_users_typed_with_custom_typed_field")
*/
class UserTypedWithCustomTypedField
{
/** @Column */
public CustomIdObject $customId;
// ...
}
.. code-block:: xml
<doctrine-mapping>
<entity name="UserTypedWithCustomTypedField">
<field name="customId"/>
<!-- -->
</entity>
</doctrine-mapping>
.. code-block:: yaml
UserTypedWithCustomTypedField:
type: entity
fields:
customId: ~
It is perfectly valid to override even the "automatic" mapping rules mentioned above:

.. code-block:: php
<?php
use App\CustomIds\CustomIdObject;
use App\DBAL\Type\CustomIdObjectType;
$configuration->setTypedFieldMapper(new DefaultTypedFieldMapper([
'int' => CustomIntType::class,
]));
.. _reference-mapping-types:

Doctrine Mapping Types
Expand Down
21 changes: 20 additions & 1 deletion lib/Doctrine/ORM/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
use Doctrine\ORM\Exception\UnknownEntityNamespace;
use Doctrine\ORM\Internal\Hydration\AbstractHydrator;
use Doctrine\ORM\Mapping\ClassMetadataFactory;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Mapping\DefaultEntityListenerResolver;
use Doctrine\ORM\Mapping\DefaultNamingStrategy;
use Doctrine\ORM\Mapping\DefaultQuoteStrategy;
use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
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;
Expand Down Expand Up @@ -63,6 +65,7 @@
* Internal note: When adding a new configuration option just write a getter/setter pair.
*
* @psalm-import-type AutogenerateMode from ProxyFactory
* @psalm-import-type ScalarName from ClassMetadataInfo
*/
class Configuration extends \Doctrine\DBAL\Configuration
{
Expand Down Expand Up @@ -719,7 +722,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)
{
Expand Down Expand Up @@ -748,6 +751,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.
*
Expand Down
31 changes: 31 additions & 0 deletions lib/Doctrine/ORM/Mapping/ChainTypedFieldMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Mapping;

final class ChainTypedFieldMapper implements TypedFieldMapper
{
/**
* @readonly
* @var TypedFieldMapper[] $typedFieldMappers
*/
private array $typedFieldMappers;

public function __construct(TypedFieldMapper ...$typedFieldMappers)
{
$this->typedFieldMappers = $typedFieldMappers;
}

/**
* {@inheritdoc}
*/
public function validateAndCompleteTypedFieldMapping($mapping, $field): array
{
foreach ($this->typedFieldMappers as $typedFieldMapper) {
$mapping = $typedFieldMapper->validateAndCompleteTypedFieldMapping($mapping, $field);
}

return $mapping;
}
}
5 changes: 3 additions & 2 deletions lib/Doctrine/ORM/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/**
* {@inheritDoc}
*
* @psalm-import-type ScalarName from ClassMetadataInfo
* @todo remove or rename ClassMetadataInfo to ClassMetadata
* @template-covariant T of object
* @template-extends ClassMetadataInfo<T>
Expand All @@ -21,8 +22,8 @@ class ClassMetadata extends ClassMetadataInfo
* @param string $entityName The name of the entity class the new instance is used for.
* @psalm-param class-string<T> $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);
}
}
6 changes: 5 additions & 1 deletion lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}

/**
Expand Down
65 changes: 12 additions & 53 deletions lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +19,6 @@
use InvalidArgumentException;
use LogicException;
use ReflectionClass;
use ReflectionEnum;
use ReflectionNamedType;
use ReflectionProperty;
use RuntimeException;
Expand Down Expand Up @@ -142,6 +137,7 @@
* type: int,
* unique?: bool,
* }
* @psalm-type ScalarName = 'array'|'bool'|'float'|'int'|'string'
*/
class ClassMetadataInfo implements ClassMetadata
{
Expand Down Expand Up @@ -800,19 +796,23 @@ 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.
*
* @param string $entityName The name of the entity class the new instance is used for.
* @psalm-param class-string<T> $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();
}

/**
Expand Down Expand Up @@ -1580,50 +1580,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->validateAndCompleteTypedFieldMapping($mapping, $field);

return $mapping;
}
Expand Down
73 changes: 73 additions & 0 deletions lib/Doctrine/ORM/Mapping/DefaultTypedFieldMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Mapping;

use DateInterval;
use DateTime;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use ReflectionEnum;
use ReflectionNamedType;

use function array_merge;
use function assert;
use function enum_exists;

use const PHP_VERSION_ID;

/** @psalm-import-type ScalarName from ClassMetadataInfo */
final class DefaultTypedFieldMapper implements TypedFieldMapper
{
/** @var array<class-string|ScalarName, class-string<Type>|string> $typedFieldMappings */
protected $typedFieldMappings;

/** @param array<class-string|ScalarName, class-string<Type>|string> $typedFieldMappings */
public function __construct($typedFieldMappings = [])
{
$this->typedFieldMappings = $typedFieldMappings;
}

/**
* {@inheritdoc}
*/
public function validateAndCompleteTypedFieldMapping($mapping, $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);
}

$defaultTypedFieldMappings = [
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,
];

$typedFieldMappings = array_merge($defaultTypedFieldMappings, $this->typedFieldMappings);

if (isset($typedFieldMappings[$type->getName()])) {
$mapping['type'] = $typedFieldMappings[$type->getName()];
}
}

return $mapping;
}
}
Loading

0 comments on commit 657f376

Please sign in to comment.