Skip to content
Merged
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
85 changes: 85 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,85 @@ 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 Money Value Object
-------------------------------------------------

You can create a custom mapping type for your own value objects or classes. For
example, to map a ``Money`` value object using the `moneyphp/money library`_, you can
implement a type that converts between this class and a BSON embedded document format.

This approach works for any custom class by adapting the conversion logic to your needs.

Example Implementation (using ``Money\Money``)::

.. code-block:: php

<?php

namespace App\MongoDB\Types;

use Doctrine\ODM\MongoDB\Types\ClosureToPHP;
use Doctrine\ODM\MongoDB\Types\Type;
use InvalidArgumentException;
use Money\Money;
use Money\Currency;

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

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

if (is_array($value) && isset($value['amount'], $value['currency'])) {
return new Money($value['amount'], new Currency($value['currency']));
}

throw new InvalidArgumentException(sprintf('Could not convert database value from "%s" to %s', get_debug_type($value), Money::class));
}

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

if ($value instanceof Money) {
return [
'amount' => $value->getAmount(),
'currency' => $value->getCurrency()->getCode(),
];
}

throw new InvalidArgumentException(sprintf('Could not convert database value from "%s" to array', get_debug_type($value)));
}
}

Register the type in your bootstrap code::

.. code-block:: php

Type::addType(Money::class, App\MongoDB\Types\MoneyType::class);

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 ?\Money\Money $price;

.. note::

This implementation of ``MoneyType`` is kept simple for illustration purposes
and does not handle all edge cases, but it should give you a good starting
point for implementing your own custom types.

.. _`moneyphp/money library`: https://github.com/moneyphp/money
.. |FQCN| raw:: html
<abbr title="Fully-Qualified Class Name">FQCN</abbr>
13 changes: 12 additions & 1 deletion src/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -2823,9 +2823,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())) {
Copy link
Member

Choose a reason for hiding this comment

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

If I'm not mistaken, this would allow us to use a FQCN as identifier for a type name to automatically link values of that class with a given type? I haven't even thought of that before, but it makes so much sense!

$mapping['type'] = $type->getName();

return $mapping;
}

Expand All @@ -2836,6 +2846,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 src/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

Copy link
Member

Choose a reason for hiding this comment

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

We could introduce a marker interface similar to how we have MongoDB\Driver\Exception\Exception in the extension.

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 src/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 @@ -157,52 +155,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);
Comment on lines -190 to -191
Copy link
Member Author

Choose a reason for hiding this comment

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

I must check why this could be removed.

Copy link
Member Author

Choose a reason for hiding this comment

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

In fact, this type detection is invalid when the property type is ObjectId:

  • the IdType class does nothing as the target type is also ObjectId
  • the IdType class convert the ObjectId received from the Database into string, which is invalid when assigning the value to the ObjectId property.

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
19 changes: 13 additions & 6 deletions tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand Down
Loading