diff --git a/docs/en/reference/custom-mapping-types.rst b/docs/en/reference/custom-mapping-types.rst
index 2e18807193..1569faa9ef 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,89 @@ 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
+
+ getType() === BsonBinary::TYPE_UUID) {
+ return RamseyUuid::fromBytes($value->getData());
+ }
+
+ throw new \InvalidArgumentException(\sprintf('Could not convert database value from "%s" to %s',get_debug_type($value),RamseyUuid::class));
+ }
+
+ public function convertToDatabaseValue(mixed $value): ?BsonBinary
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ if ($value instanceof RamseyUuid) {
+ return new BsonBinary($value->getBytes(), BsonBinary::TYPE_UUID);
+ }
+
+ throw new \InvalidArgumentException(\sprintf('Could not convert database value from "%s" to %s', get_debug_type($value), Binary::class));
+ }
+ }
+
+Register the type in your bootstrap code::
+
+.. code-block:: php
+
+ 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;
+
+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;
+
+.. 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
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..f8497eb210 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(),