Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/en/reference/custom-mapping-types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'])) {
Expand All @@ -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) {
Expand Down Expand Up @@ -115,5 +120,89 @@ specify a unique name for the mapping type and map that to the corresponding

<field field-name="field" type="date_with_timezone" />

Custom Type Example: Mapping a UUID Class
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I'm currently working on adding a type for Symfony UUIDs as I needed them for a project. That would make this entire chapter obsolete.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chapter is about mapping a DTO with a custom type. UUID is only an example, we can find an other example.

-----------------------------------------

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

<?php

namespace App\MongoDB\Types;

use Doctrine\ODM\MongoDB\Types\ClosureToPHP;
use Doctrine\ODM\MongoDB\Types\Type;
use InvalidArgumentException;
use MongoDB\BSON\Binary as BsonBinary;
use Ramsey\Uuid\Uuid as RamseyUuid;

final class RamseyUuidType extends Type
{
// This trait provides default closureToPHP used during data hydration
use ClosureToPHP;

public function convertToPHPValue(mixed $value): ?RamseyUuid
{
if (null === $value) {
return null;
}

if ($value instanceof BsonBinary && $value->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
<abbr title="Fully-Qualified Class Name">FQCN</abbr>
13 changes: 12 additions & 1 deletion lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Types/InvalidTypeException.php
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a global namespace for exception like it is generally done in libraries. Existing exception names are:

  • Doctrine\ODM\MongoDB\MongoDBException
  • Doctrine\ODM\MongoDB\ConfigurationException
  • Doctrine\ODM\MongoDB\DocumentNotFoundException
  • Doctrine\ODM\MongoDB\Mapping\MappingException
  • Doctrine\ODM\MongoDB\Hydrator\HydratorException
  • Doctrine\ODM\MongoDB\LockException

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Types;

use InvalidArgumentException;

use function sprintf;

final class InvalidTypeException extends InvalidArgumentException
{
public static function invalidTypeName(string $name): self
{
return new self(sprintf('Invalid type specified: "%s"', $name));
}
}
44 changes: 21 additions & 23 deletions lib/Doctrine/ODM/MongoDB/Types/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@

namespace Doctrine\ODM\MongoDB\Types;

use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ODM\MongoDB\Mapping\MappingException;
use Doctrine\ODM\MongoDB\Types;
use InvalidArgumentException;
use MongoDB\BSON\ObjectId;
use Symfony\Component\Uid\Uuid;

use function end;
use function explode;
use function gettype;
use function is_object;
use function sprintf;
use function str_replace;

/**
Expand Down Expand Up @@ -143,52 +141,52 @@ public static function registerType(string $name, string $class): void
/**
* Get a Type instance.
*
* @throws InvalidArgumentException
* @throws InvalidTypeException
*/
public static function getType(string $type): Type
{
if (! isset(self::$typesMap[$type])) {
throw new InvalidArgumentException(sprintf('Invalid type specified "%s".', $type));
throw InvalidTypeException::invalidTypeName($type);
}

if (! isset(self::$typeObjects[$type])) {
$className = self::$typesMap[$type];
self::$typeObjects[$type] = new $className();
}

return self::$typeObjects[$type];
return self::$typeObjects[$type] ??= new (self::$typesMap[$type]);
}

/**
* Get a Type instance based on the type of the passed php variable.
*
* @param mixed $variable
*
* @throws InvalidArgumentException
*/
public static function getTypeFromPHPVariable($variable): ?Type
{
if (is_object($variable)) {
if ($variable instanceof DateTimeInterface) {
return self::getType(self::DATE);
if ($variable instanceof DateTimeImmutable) {
return self::getType(self::DATE_IMMUTABLE);
}

if ($variable instanceof ObjectId) {
return self::getType(self::ID);
if ($variable instanceof DateTimeInterface) {
return self::getType(self::DATE);
}

if ($variable instanceof Uuid) {
return self::getType(self::UUID);
}
} else {
$type = gettype($variable);
switch ($type) {
case 'integer':
return self::getType('int');

// Try the variable class as type name
if (self::hasType($variable::class)) {
return self::getType($variable::class);
}

return null;
}

return null;
return match (gettype($variable)) {
'integer' => self::getType(self::INT),
'boolean' => self::getType(self::BOOL),
'double' => self::getType(self::FLOAT),
'string' => self::getType(self::STRING),
default => null,
};
}

/**
Expand Down
95 changes: 92 additions & 3 deletions tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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']);
}
}
Loading