Skip to content

Commit 30cb393

Browse files
committed
Improve detection of the type from a PHP variable
1 parent f22d8ec commit 30cb393

File tree

7 files changed

+318
-25
lines changed

7 files changed

+318
-25
lines changed

docs/en/reference/custom-mapping-types.rst

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ In order to create a new mapping type you need to subclass
99
``Doctrine\ODM\MongoDB\Types\Type`` and implement/override
1010
the methods.
1111

12+
Date Example: Mapping DateTimeImmutable with Timezone
13+
-----------------------------------------------------
14+
1215
The following example defines a custom type that stores ``DateTimeInterface``
1316
instances as an embedded document containing a BSON date and accompanying
1417
timezone string. Those same embedded documents are then be translated back into
@@ -115,5 +118,114 @@ specify a unique name for the mapping type and map that to the corresponding
115118
116119
<field field-name="field" type="date_with_timezone" />
117120
121+
Custom Type Example: Mapping a UUID Class
122+
-----------------------------------------
123+
124+
You can create a custom mapping type for your own value objects or classes. For
125+
example, to map a UUID value object using the `ramsey/uuid library`_, you can
126+
implement a type that converts between your class and the BSON Binary UUID format.
127+
128+
This approach works for any custom class by adapting the conversion logic to your needs.
129+
130+
Example Implementation (using ``Ramsey\Uuid\Uuid``)::
131+
132+
.. code-block:: php
133+
134+
<?php
135+
136+
namespace My\Project\Types;
137+
138+
use Doctrine\ODM\MongoDB\Types\ClosureToPHP;
139+
use Doctrine\ODM\MongoDB\Types\Type;
140+
use InvalidArgumentException;
141+
use MongoDB\BSON\Binary;
142+
use Ramsey\Uuid\Uuid;
143+
use Ramsey\Uuid\UuidInterface;
144+
145+
final class UuidType extends Type
146+
{
147+
// This trait provides default closureToPHP used during data hydration
148+
use ClosureToPHP;
149+
150+
public function convertToPHPValue(mixed $value): ?Uuid
151+
{
152+
if (null === $value) {
153+
return null;
154+
}
155+
156+
if ($value instanceof Uuid) {
157+
return $value;
158+
}
159+
160+
if ($value instanceof Binary) {
161+
return Uuid::fromBytes($value->getData());
162+
}
163+
164+
if (is_string($value) && Uuid::isValid($value)) {
165+
return Uuid::fromString($value);
166+
}
167+
168+
throw new InvalidArgumentException(
169+
sprintf(
170+
'Could not convert database value "%s" from "%s" to %s',
171+
$value,
172+
get_debug_type($value),
173+
UuidInterface::class
174+
)
175+
);
176+
}
177+
178+
public function convertToDatabaseValue(mixed $value): ?Binary
179+
{
180+
if (null === $value || [] === $value) {
181+
return null;
182+
}
183+
184+
if ($value instanceof Binary) {
185+
return $value;
186+
}
187+
188+
if (is_string($value) && Uuid::isValid($value)) {
189+
$value = Uuid::fromString($value)->getBytes();
190+
}
191+
192+
if ($value instanceof Uuid) {
193+
return new Binary($value->getBytes(), Binary::TYPE_UUID);
194+
}
195+
196+
throw new InvalidArgumentException(
197+
sprintf(
198+
'Could not convert database value "%s" from "%s" to %s',
199+
$value,
200+
get_debug_type($value),
201+
Binary::class
202+
)
203+
);
204+
}
205+
}
206+
207+
Register the type in your bootstrap code::
208+
209+
.. code-block:: php
210+
211+
Type::addType(Ramsey\Uuid\Uuid::class, My\Project\Types\UuidType::class);
212+
213+
Usage Example::
214+
215+
.. code-block:: php
216+
217+
#[Field(type: Ramsey\Uuid\Uuid::class)]
218+
public Ramsey\Uuid\Uuid $id;
219+
220+
By using the |FQCN| of the value object class as the type name, the type is
221+
automatically used when encountering a property of that class. This means you
222+
can omit the ``type`` option when defining the field mapping::
223+
224+
.. code-block:: php
225+
226+
#[Field]
227+
public Ramsey\Uuid\Uuid $id;
228+
229+
.. _`ramsey/uuid library`: https://github.com/ramsey/uuid
118230
.. |FQCN| raw:: html
119231
<abbr title="Fully-Qualified Class Name">FQCN</abbr>

lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2786,9 +2786,19 @@ private function isTypedProperty(string $name): bool
27862786
*/
27872787
private function validateAndCompleteTypedFieldMapping(array $mapping): array
27882788
{
2789+
if (isset($mapping['type'])) {
2790+
return $mapping;
2791+
}
2792+
27892793
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
27902794

2791-
if (! $type instanceof ReflectionNamedType || isset($mapping['type'])) {
2795+
if (! $type instanceof ReflectionNamedType) {
2796+
return $mapping;
2797+
}
2798+
2799+
if (! $type->isBuiltin() && Type::hasType($type->getName())) {
2800+
$mapping['type'] = $type->getName();
2801+
27922802
return $mapping;
27932803
}
27942804

@@ -2799,6 +2809,7 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array
27992809
throw MappingException::nonBackedEnumMapped($this->name, $mapping['fieldName'], $reflection->getName());
28002810
}
28012811

2812+
// Use the backing type of the enum for the mapping type
28022813
$type = $reflection->getBackingType();
28032814
assert($type instanceof ReflectionNamedType);
28042815
$mapping['enumType'] = $reflection->getName();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ODM\MongoDB\Types;
6+
7+
use InvalidArgumentException;
8+
9+
use function sprintf;
10+
11+
final class InvalidTypeException extends InvalidArgumentException
12+
{
13+
public static function invalidTypeName(string $name): self
14+
{
15+
return new self(sprintf('Invalid type specified: "%s"', $name));
16+
}
17+
}

lib/Doctrine/ODM/MongoDB/Types/Type.php

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,17 @@
44

55
namespace Doctrine\ODM\MongoDB\Types;
66

7+
use DateTimeImmutable;
78
use DateTimeInterface;
89
use Doctrine\ODM\MongoDB\Mapping\MappingException;
910
use Doctrine\ODM\MongoDB\Types;
10-
use InvalidArgumentException;
1111
use MongoDB\BSON\ObjectId;
1212
use Symfony\Component\Uid\Uuid;
1313

1414
use function end;
1515
use function explode;
1616
use function gettype;
1717
use function is_object;
18-
use function sprintf;
1918
use function str_replace;
2019

2120
/**
@@ -143,32 +142,29 @@ public static function registerType(string $name, string $class): void
143142
/**
144143
* Get a Type instance.
145144
*
146-
* @throws InvalidArgumentException
145+
* @throws InvalidTypeException
147146
*/
148147
public static function getType(string $type): Type
149148
{
150149
if (! isset(self::$typesMap[$type])) {
151-
throw new InvalidArgumentException(sprintf('Invalid type specified "%s".', $type));
150+
throw InvalidTypeException::invalidTypeName($type);
152151
}
153152

154-
if (! isset(self::$typeObjects[$type])) {
155-
$className = self::$typesMap[$type];
156-
self::$typeObjects[$type] = new $className();
157-
}
158-
159-
return self::$typeObjects[$type];
153+
return self::$typeObjects[$type] ??= new (self::$typesMap[$type]);
160154
}
161155

162156
/**
163157
* Get a Type instance based on the type of the passed php variable.
164158
*
165159
* @param mixed $variable
166-
*
167-
* @throws InvalidArgumentException
168160
*/
169161
public static function getTypeFromPHPVariable($variable): ?Type
170162
{
171163
if (is_object($variable)) {
164+
if ($variable instanceof DateTimeImmutable) {
165+
return self::getType(self::DATE_IMMUTABLE);
166+
}
167+
172168
if ($variable instanceof DateTimeInterface) {
173169
return self::getType(self::DATE);
174170
}
@@ -180,15 +176,22 @@ public static function getTypeFromPHPVariable($variable): ?Type
180176
if ($variable instanceof Uuid) {
181177
return self::getType(self::UUID);
182178
}
183-
} else {
184-
$type = gettype($variable);
185-
switch ($type) {
186-
case 'integer':
187-
return self::getType('int');
179+
180+
// Try the variable class as type name
181+
if (self::hasType($variable::class)) {
182+
return self::getType($variable::class);
188183
}
184+
185+
return null;
189186
}
190187

191-
return null;
188+
return match (gettype($variable)) {
189+
'integer' => self::getType(self::INT),
190+
'boolean' => self::getType(self::BOOL),
191+
'double' => self::getType(self::FLOAT),
192+
'string' => self::getType(self::STRING),
193+
default => null,
194+
};
192195
}
193196

194197
/**

tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,34 @@
1010
use Doctrine\ODM\MongoDB\Types\ClosureToPHP;
1111
use Doctrine\ODM\MongoDB\Types\Type;
1212
use Exception;
13+
use PHPUnit\Framework\Attributes\After;
14+
use PHPUnit\Framework\Attributes\Before;
15+
use ReflectionProperty;
1316

1417
use function array_map;
1518
use function array_values;
19+
use function assert;
1620
use function is_array;
1721

1822
class CustomTypeTest extends BaseTestCase
1923
{
20-
public static function setUpBeforeClass(): void
24+
/** @var string[] */
25+
private array $originalTypeMap = [];
26+
27+
#[Before]
28+
public function backupTypeMap(): void
2129
{
30+
$this->originalTypeMap = (new ReflectionProperty(Type::class, 'typesMap'))->getValue();
31+
2232
Type::addType('date_collection', DateCollectionType::class);
33+
Type::addType(Language::class, LanguageType::class);
34+
}
35+
36+
#[After]
37+
public function restoreTypeMap(): void
38+
{
39+
(new ReflectionProperty(Type::class, 'typesMap'))->setValue($this->originalTypeMap);
40+
unset($this->originalTypeMap);
2341
}
2442

2543
public function testCustomTypeValueConversions(): void
@@ -46,6 +64,36 @@ public function testConvertToDatabaseValueExpectsArray(): void
4664
$this->expectException(CustomTypeException::class);
4765
$this->dm->flush();
4866
}
67+
68+
public function testCustomTypeDetection(): void
69+
{
70+
$typeOfField = $this->dm->getClassMetadata(Country::class)->getTypeOfField('lang');
71+
self::assertSame(Language::class, $typeOfField, 'The custom type should be detected on the field');
72+
73+
$country = new Country();
74+
$country->lang = new Language('French', 'fr');
75+
76+
$this->dm->persist($country);
77+
$this->dm->flush();
78+
$this->dm->clear();
79+
80+
$country = $this->dm->find(Country::class, $country->id);
81+
82+
self::assertNotNull($country);
83+
self::assertInstanceOf(Language::class, $country->lang);
84+
self::assertSame('French', $country->lang->name);
85+
self::assertSame('fr', $country->lang->code);
86+
}
87+
88+
public function testTypeFromPHPVariable(): void
89+
{
90+
$lang = new Language('French', 'fr');
91+
$type = Type::getTypeFromPHPVariable($lang);
92+
self::assertInstanceOf(LanguageType::class, $type);
93+
94+
$databaseValue = Type::convertPHPToDatabaseValue($lang);
95+
self::assertSame(['name' => 'French', 'code' => 'fr'], $databaseValue);
96+
}
4997
}
5098

5199
class DateCollectionType extends Type
@@ -106,11 +154,52 @@ class CustomTypeException extends Exception
106154
#[ODM\Document]
107155
class Country
108156
{
109-
/** @var string|null */
110157
#[ODM\Id]
111-
public $id;
158+
public ?string $id;
112159

113160
/** @var DateTime[]|DateTime|null */
114161
#[ODM\Field(type: 'date_collection')]
115162
public $nationalHolidays;
163+
164+
/** The field type is detected from the property type */
165+
#[ODM\Field(/* type: Language::class */)]
166+
public Language $lang;
167+
}
168+
169+
class Language
170+
{
171+
public function __construct(
172+
public string $name,
173+
public string $code,
174+
) {
175+
}
176+
}
177+
178+
class LanguageType extends Type
179+
{
180+
use ClosureToPHP;
181+
182+
/** @return array{name:string,code:string}|null */
183+
public function convertToDatabaseValue($value): ?array
184+
{
185+
if ($value === null) {
186+
return null;
187+
}
188+
189+
assert($value instanceof Language);
190+
191+
return ['name' => $value->name, 'code' => $value->code];
192+
}
193+
194+
/** @param array{name:string,code:string}|null $value */
195+
public function convertToPHPValue($value): ?Language
196+
{
197+
if ($value === null) {
198+
return null;
199+
}
200+
201+
assert(is_array($value) && isset($value['name'], $value['code']));
202+
203+
return new Language($value['name'], $value['code']);
204+
}
116205
}

0 commit comments

Comments
 (0)