From ff5cd77e0c8c7a8eceef9435582c6ba9a38e148c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 1 Sep 2025 17:50:33 +0200 Subject: [PATCH 1/2] Improve detection of the type from a PHP variable --- docs/en/reference/custom-mapping-types.rst | 114 ++++++++++++++++++ .../ODM/MongoDB/Mapping/ClassMetadata.php | 13 +- .../MongoDB/Types/InvalidTypeException.php | 17 +++ lib/Doctrine/ODM/MongoDB/Types/Type.php | 44 ++++--- .../Tests/Functional/CustomTypeTest.php | 95 ++++++++++++++- .../Tests/Functional/Ticket/GH2789Test.php | 24 +++- .../ODM/MongoDB/Tests/Types/TypeTest.php | 42 +++++++ .../DateTimeWithTimezoneType.php | 19 ++- 8 files changed, 332 insertions(+), 36 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Types/InvalidTypeException.php diff --git a/docs/en/reference/custom-mapping-types.rst b/docs/en/reference/custom-mapping-types.rst index 2e18807193..32c7bd46c9 100644 --- a/docs/en/reference/custom-mapping-types.rst +++ b/docs/en/reference/custom-mapping-types.rst @@ -9,6 +9,9 @@ In order to create a new mapping type you need to subclass ``Doctrine\ODM\MongoDB\Types\Type`` and implement/override the methods. +Date Example: Mapping DateTimeImmutable with Timezone +----------------------------------------------------- + The following example defines a custom type that stores ``DateTimeInterface`` instances as an embedded document containing a BSON date and accompanying timezone string. Those same embedded documents are then be translated back into @@ -32,6 +35,7 @@ a ``DateTimeImmutable`` when the data is read from the database. // This trait provides default closureToPHP used during data hydration use ClosureToPHP; + /** @param array{utc: UTCDateTime, tz: string} $value */ public function convertToPHPValue($value): DateTimeImmutable { if (!isset($value['utc'], $value['tz'])) { @@ -46,6 +50,7 @@ a ``DateTimeImmutable`` when the data is read from the database. return DateTimeImmutable::createFromMutable($dateTime); } + /** @return array{utc: UTCDateTime, tz: string} */ public function convertToDatabaseValue($value): array { if (!$value instanceof DateTimeImmutable) { @@ -115,5 +120,114 @@ specify a unique name for the mapping type and map that to the corresponding +Custom Type Example: Mapping a UUID Class +----------------------------------------- + +You can create a custom mapping type for your own value objects or classes. For +example, to map a UUID value object using the `ramsey/uuid library`_, you can +implement a type that converts between your class and the BSON Binary UUID format. + +This approach works for any custom class by adapting the conversion logic to your needs. + +Example Implementation (using ``Ramsey\Uuid\Uuid``):: + +.. code-block:: php + + getData()); + } + + if (is_string($value) && Uuid::isValid($value)) { + return Uuid::fromString($value); + } + + throw new InvalidArgumentException( + sprintf( + 'Could not convert database value "%s" from "%s" to %s', + $value, + get_debug_type($value), + UuidInterface::class + ) + ); + } + + public function convertToDatabaseValue(mixed $value): ?Binary + { + if (null === $value || [] === $value) { + return null; + } + + if ($value instanceof Binary) { + return $value; + } + + if (is_string($value) && Uuid::isValid($value)) { + $value = Uuid::fromString($value)->getBytes(); + } + + if ($value instanceof Uuid) { + return new Binary($value->getBytes(), Binary::TYPE_UUID); + } + + throw new InvalidArgumentException( + sprintf( + 'Could not convert database value "%s" from "%s" to %s', + $value, + get_debug_type($value), + Binary::class + ) + ); + } + } + +Register the type in your bootstrap code:: + +.. code-block:: php + + Type::addType(Ramsey\Uuid\Uuid::class, My\Project\Types\UuidType::class); + +Usage Example:: + +.. code-block:: php + + #[Field(type: Ramsey\Uuid\Uuid::class)] + public Ramsey\Uuid\Uuid $id; + +By using the |FQCN| of the value object class as the type name, the type is +automatically used when encountering a property of that class. This means you +can omit the ``type`` option when defining the field mapping:: + +.. code-block:: php + + #[Field] + public Ramsey\Uuid\Uuid $id; + +.. _`ramsey/uuid library`: https://github.com/ramsey/uuid .. |FQCN| raw:: html FQCN diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 3088a770e4..a2180f3dda 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -2786,9 +2786,19 @@ private function isTypedProperty(string $name): bool */ private function validateAndCompleteTypedFieldMapping(array $mapping): array { + if (isset($mapping['type'])) { + return $mapping; + } + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); - if (! $type instanceof ReflectionNamedType || isset($mapping['type'])) { + if (! $type instanceof ReflectionNamedType) { + return $mapping; + } + + if (! $type->isBuiltin() && Type::hasType($type->getName())) { + $mapping['type'] = $type->getName(); + return $mapping; } @@ -2799,6 +2809,7 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array throw MappingException::nonBackedEnumMapped($this->name, $mapping['fieldName'], $reflection->getName()); } + // Use the backing type of the enum for the mapping type $type = $reflection->getBackingType(); assert($type instanceof ReflectionNamedType); $mapping['enumType'] = $reflection->getName(); diff --git a/lib/Doctrine/ODM/MongoDB/Types/InvalidTypeException.php b/lib/Doctrine/ODM/MongoDB/Types/InvalidTypeException.php new file mode 100644 index 0000000000..defe905e79 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Types/InvalidTypeException.php @@ -0,0 +1,17 @@ + self::getType(self::INT), + 'boolean' => self::getType(self::BOOL), + 'double' => self::getType(self::FLOAT), + 'string' => self::getType(self::STRING), + default => null, + }; } /** diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php index 3a123f5051..99b9d37ff1 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php @@ -10,16 +10,34 @@ use Doctrine\ODM\MongoDB\Types\ClosureToPHP; use Doctrine\ODM\MongoDB\Types\Type; use Exception; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use ReflectionProperty; use function array_map; use function array_values; +use function assert; use function is_array; class CustomTypeTest extends BaseTestCase { - public static function setUpBeforeClass(): void + /** @var string[] */ + private array $originalTypeMap = []; + + #[Before] + public function backupTypeMap(): void { + $this->originalTypeMap = (new ReflectionProperty(Type::class, 'typesMap'))->getValue(); + Type::addType('date_collection', DateCollectionType::class); + Type::addType(Language::class, LanguageType::class); + } + + #[After] + public function restoreTypeMap(): void + { + (new ReflectionProperty(Type::class, 'typesMap'))->setValue($this->originalTypeMap); + unset($this->originalTypeMap); } public function testCustomTypeValueConversions(): void @@ -46,6 +64,36 @@ public function testConvertToDatabaseValueExpectsArray(): void $this->expectException(CustomTypeException::class); $this->dm->flush(); } + + public function testCustomTypeDetection(): void + { + $typeOfField = $this->dm->getClassMetadata(Country::class)->getTypeOfField('lang'); + self::assertSame(Language::class, $typeOfField, 'The custom type should be detected on the field'); + + $country = new Country(); + $country->lang = new Language('French', 'fr'); + + $this->dm->persist($country); + $this->dm->flush(); + $this->dm->clear(); + + $country = $this->dm->find(Country::class, $country->id); + + self::assertNotNull($country); + self::assertInstanceOf(Language::class, $country->lang); + self::assertSame('French', $country->lang->name); + self::assertSame('fr', $country->lang->code); + } + + public function testTypeFromPHPVariable(): void + { + $lang = new Language('French', 'fr'); + $type = Type::getTypeFromPHPVariable($lang); + self::assertInstanceOf(LanguageType::class, $type); + + $databaseValue = Type::convertPHPToDatabaseValue($lang); + self::assertSame(['name' => 'French', 'code' => 'fr'], $databaseValue); + } } class DateCollectionType extends Type @@ -106,11 +154,52 @@ class CustomTypeException extends Exception #[ODM\Document] class Country { - /** @var string|null */ #[ODM\Id] - public $id; + public ?string $id; /** @var DateTime[]|DateTime|null */ #[ODM\Field(type: 'date_collection')] public $nationalHolidays; + + /** The field type is detected from the property type */ + #[ODM\Field(/* type: Language::class */)] + public Language $lang; +} + +class Language +{ + public function __construct( + public string $name, + public string $code, + ) { + } +} + +class LanguageType extends Type +{ + use ClosureToPHP; + + /** @return array{name:string,code:string}|null */ + public function convertToDatabaseValue($value): ?array + { + if ($value === null) { + return null; + } + + assert($value instanceof Language); + + return ['name' => $value->name, 'code' => $value->code]; + } + + /** @param array{name:string,code:string}|null $value */ + public function convertToPHPValue($value): ?Language + { + if ($value === null) { + return null; + } + + assert(is_array($value) && isset($value['name'], $value['code'])); + + return new Language($value['name'], $value['code']); + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH2789Test.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH2789Test.php index bae9c72e30..a9eebeabe2 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH2789Test.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH2789Test.php @@ -9,17 +9,35 @@ use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\ODM\MongoDB\Types\Versionable; use MongoDB\BSON\Binary; -use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use ReflectionProperty; use function assert; use function is_int; -#[BackupGlobals(true)] class GH2789Test extends BaseTestCase { - public function testVersionWithCustomType(): void + /** @var string[] */ + private array $originalTypeMap = []; + + #[Before] + public function backupTypeMap(): void { + $this->originalTypeMap = (new ReflectionProperty(Type::class, 'typesMap'))->getValue(); + Type::addType(GH2789CustomType::class, GH2789CustomType::class); + } + + #[After] + public function restoreTypeMap(): void + { + (new ReflectionProperty(Type::class, 'typesMap'))->setValue($this->originalTypeMap); + unset($this->originalTypeMap); + } + + public function testVersionWithCustomType(): void + { $doc = new GH2789VersionedUuid('original message'); $this->dm->persist($doc); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Types/TypeTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Types/TypeTest.php index 25fa434917..d1219b3a29 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Types/TypeTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Types/TypeTest.php @@ -7,6 +7,7 @@ use DateTime; use DateTimeImmutable; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; +use Doctrine\ODM\MongoDB\Types\InvalidTypeException; use Doctrine\ODM\MongoDB\Types\Type; use MongoDB\BSON\Binary; use MongoDB\BSON\Decimal128; @@ -22,6 +23,7 @@ use function get_debug_type; use function hex2bin; use function md5; +use function sprintf; use function str_pad; use function str_repeat; use function time; @@ -129,6 +131,46 @@ public function testConvertImmutableDate(): void self::assertInstanceOf(UTCDateTime::class, Type::convertPHPToDatabaseValue($date)); } + #[DataProvider('provideTypeFromPHPVariable')] + public function testGetTypeFromPHPVariable(?Type $expectedType, mixed $variable): void + { + $type = Type::getTypeFromPHPVariable($variable); + + if ($expectedType === null) { + self::assertNull($type); + } elseif ($type === null) { + self::fail(sprintf('Type is null, expected "%s"', $expectedType::class)); + } else { + self::assertInstanceOf($expectedType::class, $type, $type::class); + } + } + + public static function provideTypeFromPHPVariable(): array + { + return [ + 'null' => [null, null], + 'bool' => [Type::getType(Type::BOOL), true], + 'int' => [Type::getType(Type::INT), 1], + 'float' => [Type::getType(Type::FLOAT), 3.14], + 'string' => [Type::getType(Type::STRING), 'ohai'], + 'DateTime' => [Type::getType(Type::DATE), new DateTime()], + 'DateTimeImmutable' => [Type::getType(Type::DATE_IMMUTABLE), new DateTimeImmutable()], + 'unknown object' => [ + null, + new class () { + }, + ], + ]; + } + + public function testInvalidType(): void + { + self::expectException(InvalidTypeException::class); + self::expectExceptionMessage('Invalid type specified: "foo"'); + + Type::getType('foo'); + } + private static function assertSameTypeAndValue(mixed $expected, mixed $actual): void { self::assertSame(get_debug_type($expected), get_debug_type($actual)); diff --git a/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php b/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php index 39e2395a05..26ee750e4c 100644 --- a/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php +++ b/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php @@ -5,13 +5,15 @@ namespace Documentation\CustomMapping; use DateTimeImmutable; -use DateTimeInterface; use DateTimeZone; use Doctrine\ODM\MongoDB\Types\ClosureToPHP; use Doctrine\ODM\MongoDB\Types\Type; use MongoDB\BSON\UTCDateTime; use RuntimeException; +use function gettype; +use function sprintf; + class DateTimeWithTimezoneType extends Type { // This trait provides default closureToPHP used during data hydration @@ -32,13 +34,18 @@ public function convertToPHPValue($value): DateTimeImmutable return DateTimeImmutable::createFromMutable($dateTime); } - /** - * @param DateTimeInterface $value - * - * @return array{utc: UTCDateTime, tz: string} - */ + /** @return array{utc: UTCDateTime, tz: string} */ public function convertToDatabaseValue($value): array { + if (! $value instanceof DateTimeImmutable) { + throw new RuntimeException( + sprintf( + 'Expected instance of \DateTimeImmutable, got %s', + gettype($value), + ), + ); + } + return [ 'utc' => new UTCDateTime($value), 'tz' => $value->getTimezone()->getName(), From f0ecc10e88ae3ab67e0ff660b781e6a8374704b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Oct 2025 16:27:07 +0200 Subject: [PATCH 2/2] Simplify type class code to the essential --- docs/en/reference/custom-mapping-types.rst | 71 ++++++------------- .../Tests/Functional/CustomTypeTest.php | 2 +- 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/docs/en/reference/custom-mapping-types.rst b/docs/en/reference/custom-mapping-types.rst index 32c7bd46c9..1569faa9ef 100644 --- a/docs/en/reference/custom-mapping-types.rst +++ b/docs/en/reference/custom-mapping-types.rst @@ -135,74 +135,43 @@ Example Implementation (using ``Ramsey\Uuid\Uuid``):: getData()); + if ($value instanceof BsonBinary && $value->getType() === BsonBinary::TYPE_UUID) { + return RamseyUuid::fromBytes($value->getData()); } - if (is_string($value) && Uuid::isValid($value)) { - return Uuid::fromString($value); - } - - throw new InvalidArgumentException( - sprintf( - 'Could not convert database value "%s" from "%s" to %s', - $value, - get_debug_type($value), - UuidInterface::class - ) - ); + throw new \InvalidArgumentException(\sprintf('Could not convert database value from "%s" to %s',get_debug_type($value),RamseyUuid::class)); } - public function convertToDatabaseValue(mixed $value): ?Binary + public function convertToDatabaseValue(mixed $value): ?BsonBinary { - if (null === $value || [] === $value) { + if (null === $value) { return null; } - if ($value instanceof Binary) { - return $value; + if ($value instanceof RamseyUuid) { + return new BsonBinary($value->getBytes(), BsonBinary::TYPE_UUID); } - if (is_string($value) && Uuid::isValid($value)) { - $value = Uuid::fromString($value)->getBytes(); - } - - if ($value instanceof Uuid) { - return new Binary($value->getBytes(), Binary::TYPE_UUID); - } - - throw new InvalidArgumentException( - sprintf( - 'Could not convert database value "%s" from "%s" to %s', - $value, - get_debug_type($value), - Binary::class - ) - ); + throw new \InvalidArgumentException(\sprintf('Could not convert database value from "%s" to %s', get_debug_type($value), Binary::class)); } } @@ -210,14 +179,14 @@ Register the type in your bootstrap code:: .. code-block:: php - Type::addType(Ramsey\Uuid\Uuid::class, My\Project\Types\UuidType::class); + Type::addType(Ramsey\Uuid\Uuid::class, App\MongoDB\Types\RamseyUuidType::class); Usage Example:: .. code-block:: php - #[Field(type: Ramsey\Uuid\Uuid::class)] - public Ramsey\Uuid\Uuid $id; + #[Field(type: \Ramsey\Uuid\Uuid::class)] + public ?\Ramsey\Uuid\Uuid $id; By using the |FQCN| of the value object class as the type name, the type is automatically used when encountering a property of that class. This means you @@ -226,7 +195,13 @@ can omit the ``type`` option when defining the field mapping:: .. code-block:: php #[Field] - public Ramsey\Uuid\Uuid $id; + public ?\Ramsey\Uuid\Uuid $id; + +.. note:: + + This implementation of ``RamseyUuidType`` is volontary simple and does not + handle all edge cases, but it should give you a good starting point for + implementing your own custom types. .. _`ramsey/uuid library`: https://github.com/ramsey/uuid .. |FQCN| raw:: html diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php index 99b9d37ff1..f8497eb210 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php @@ -163,7 +163,7 @@ class Country /** The field type is detected from the property type */ #[ODM\Field(/* type: Language::class */)] - public Language $lang; + public ?Language $lang; } class Language