Skip to content

Commit

Permalink
Use typed properties for default metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
franmomu committed Mar 8, 2022
1 parent 9d22a8f commit 7276a92
Show file tree
Hide file tree
Showing 10 changed files with 375 additions and 6 deletions.
15 changes: 15 additions & 0 deletions UPGRADE-2.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# UPGRADE FROM 2.3 to 2.4

## Typed properties as default mapping metadata

When using typed properties on Document classes, Doctrine will use these types to set defaults mapping types.

If you have defined some properties like:

```php
#[Field]
private int $myProp;
```

This property will be stored in DB as `string` but casted back to `int`. Please note that at this
time, due to backward compatibility reasons, nullable type does not imply `nullable` mapping.
15 changes: 10 additions & 5 deletions docs/en/reference/annotations-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ Optional attributes:
information.
-
``collectionClass`` - A |FQCN| of class that implements ``Collection``
interface and is used to hold documents. Doctrine's ``ArrayCollection`` is
interface and is used to hold documents. When typed properties
are used it is inherited from PHP type, otherwise Doctrine's ``ArrayCollection`` is
used by default.
-
``notSaved`` - The property is loaded if it exists in the database; however,
Expand Down Expand Up @@ -257,7 +258,8 @@ following excerpt from the MongoDB documentation:
Optional attributes:

-
``targetDocument`` - A |FQCN| of the target document.
``targetDocument`` - A |FQCN| of the target document. When typed properties
are used it is inherited from PHP type.
-
``discriminatorField`` - The database field name to store the discriminator
value within the embedded document.
Expand Down Expand Up @@ -356,7 +358,8 @@ Optional attributes:
-
``type`` - Name of the ODM type, which will determine the value's
representation in PHP and BSON (i.e. MongoDB). See
:ref:`doctrine_mapping_types` for a list of types. Defaults to "string".
:ref:`doctrine_mapping_types` for a list of types. Defaults to "string" or
:ref:`Type from PHP property type <reference-php-mapping-types>`.
-
``name`` - By default, the property name is used for the field name in
MongoDB; however, this option may be used to specify a database field name.
Expand Down Expand Up @@ -961,7 +964,8 @@ Optional attributes:
information.
-
``collectionClass`` - A |FQCN| of class that implements ``Collection``
interface and is used to hold documents. Doctrine's ``ArrayCollection`` is
interface and is used to hold documents. When typed properties
are used it is inherited from PHP type, otherwise Doctrine's ``ArrayCollection`` is
used by default
-
``prime`` - A list of references contained in the target document that will
Expand Down Expand Up @@ -1002,7 +1006,8 @@ Optional attributes:

-
``targetDocument`` - A |FQCN| of the target document. A ``targetDocument``
is required when using ``storeAs: id``.
is required when using ``storeAs: id``. When typed properties are used
it is inherited from PHP type.
-
``storeAs`` - Indicates how to store the reference. ``id`` stores the
identifier, ``ref`` an embedded object containing the ``id`` field and
Expand Down
19 changes: 19 additions & 0 deletions docs/en/reference/basic-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,25 @@ This list explains some of the less obvious mapping types:
suitable you should either use an embedded document or use formats provided
by the MongoDB driver (e.g. ``\MongoDB\BSON\UTCDateTime`` instead of ``\DateTime``).

.. _reference-php-mapping-types:

PHP Types Mapping
_________________

Since version 2.4 Doctrine can determine usable defaults from property types
on document classes. Doctrine will map PHP types to ``type`` attribute as
follows:

- ``DateTime``: ``date``
- ``DateTimeImmutable``: ``date_immutable``
- ``array``: ``hash``
- ``bool``: ``bool``
- ``float``: ``float``
- ``int``: ``int``
- ``string``: ``string``

Please note that at this time, due to backward compatibility reasons, nullable type does not imply `nullable` mapping.

Property Mapping
----------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ abstract class AbstractField implements Annotation
*/
public function __construct(
?string $name = null,
?string $type = 'string',
?string $type = null,
bool $nullable = false,
array $options = [],
?string $strategy = null,
Expand Down
107 changes: 107 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Doctrine\ODM\MongoDB\Mapping;

use BadMethodCallException;
use DateTime;
use DateTimeImmutable;
use Doctrine\Instantiator\Instantiator;
use Doctrine\Instantiator\InstantiatorInterface;
use Doctrine\ODM\MongoDB\Id\IdGenerator;
Expand All @@ -20,6 +22,7 @@
use LogicException;
use ProxyManager\Proxy\GhostObjectInterface;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;

use function array_filter;
Expand All @@ -43,6 +46,8 @@
use function strtoupper;
use function trigger_deprecation;

use const PHP_VERSION_ID;

/**
* A <tt>ClassMetadata</tt> instance holds all the object-document mapping metadata
* of a document and it's references.
Expand Down Expand Up @@ -1525,6 +1530,11 @@ public function mapOneEmbedded(array $mapping): void
{
$mapping['embedded'] = true;
$mapping['type'] = self::ONE;

if ($this->isTypedProperty($mapping['fieldName'])) {
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping);
}

$this->mapField($mapping);
}

Expand All @@ -1549,6 +1559,11 @@ public function mapOneReference(array $mapping): void
{
$mapping['reference'] = true;
$mapping['type'] = self::ONE;

if ($this->isTypedProperty($mapping['fieldName'])) {
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping);
}

$this->mapField($mapping);
}

Expand Down Expand Up @@ -2195,6 +2210,19 @@ public function mapField(array $mapping): array
unset($this->generatorOptions['type']);
}

if ($this->isTypedProperty($mapping['fieldName'])) {
$mapping = $this->validateAndCompleteTypedFieldMapping($mapping);

if (isset($mapping['type']) && ($mapping['type'] === self::ONE || $mapping['type'] === self::MANY)) {
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping);
}
}

if (! isset($mapping['type'])) {
// Default to string
$mapping['type'] = Type::STRING;
}

if (! isset($mapping['nullable'])) {
$mapping['nullable'] = false;
}
Expand Down Expand Up @@ -2505,4 +2533,83 @@ private function checkDuplicateMapping(array $mapping): void
throw MappingException::duplicateDatabaseFieldName($this->getName(), $mapping['fieldName'], $mapping['name'], $fieldName);
}
}

private function isTypedProperty(string $name): bool
{
return PHP_VERSION_ID >= 70400
&& $this->reflClass->hasProperty($name)
&& $this->reflClass->getProperty($name)->hasType();
}

/**
* Validates & completes the given field mapping based on typed property.
*
* @psalm-param FieldMappingConfig $mapping
*
* @return FieldMappingConfig
*/
private function validateAndCompleteTypedFieldMapping(array $mapping): array
{
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();

if (! $type instanceof ReflectionNamedType || isset($mapping['type'])) {
return $mapping;
}

switch ($type->getName()) {
case DateTime::class:
$mapping['type'] = Type::DATE;
break;
case DateTimeImmutable::class:
$mapping['type'] = Type::DATE_IMMUTABLE;
break;
case 'array':
$mapping['type'] = Type::HASH;
break;
case 'bool':
$mapping['type'] = Type::BOOL;
break;
case 'float':
$mapping['type'] = Type::FLOAT;
break;
case 'int':
$mapping['type'] = Type::INT;
break;
case 'string':
$mapping['type'] = Type::STRING;
break;
}

return $mapping;
}

/**
* Validates & completes the basic mapping information based on typed property.
*
* @psalm-param FieldMappingConfig $mapping
*
* @return FieldMappingConfig
*/
private function validateAndCompleteTypedAssociationMapping(array $mapping): array
{
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();

if (! $type instanceof ReflectionNamedType) {
return $mapping;
}

if (! isset($mapping['targetDocument']) && $mapping['type'] === self::ONE) {
$mapping['targetDocument'] = $type->getName();
}

if (
! isset($mapping['collectionClass'])
&& $mapping['type'] === self::MANY
&& class_exists($type->getName())
) {
$mapping['collectionClass'] = $type->getName();
}

return $mapping;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\ODM\MongoDB\Repository\ViewRepository;
use Doctrine\ODM\MongoDB\Tests\BaseTest;
use Doctrine\ODM\MongoDB\Types\Type;
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
use Documents74\CustomCollection;
use Documents74\TypedEmbeddedDocument;
use Documents74\UserTyped;
use InvalidArgumentException;

use function key;
Expand Down Expand Up @@ -194,6 +198,28 @@ public function testIdentifier(ClassMetadata $class): ClassMetadata
return $class;
}

/**
* @requires PHP >= 7.4
*/
public function testFieldTypeFromReflection(): void
{
$class = $this->dm->getClassMetadata(UserTyped::class);

$this->assertSame(Type::ID, $class->getTypeOfField('id'));
$this->assertSame(Type::STRING, $class->getTypeOfField('username'));
$this->assertSame(Type::DATE, $class->getTypeOfField('dateTime'));
$this->assertSame(Type::DATE_IMMUTABLE, $class->getTypeOfField('dateTimeImmutable'));
$this->assertSame(Type::HASH, $class->getTypeOfField('array'));
$this->assertSame(Type::BOOL, $class->getTypeOfField('boolean'));
$this->assertSame(Type::FLOAT, $class->getTypeOfField('float'));

$this->assertSame(TypedEmbeddedDocument::class, $class->getAssociationTargetClass('embedOne'));
$this->assertSame(UserTyped::class, $class->getAssociationTargetClass('referenceOne'));

$this->assertSame(CustomCollection::class, $class->getAssociationCollectionClass('embedMany'));
$this->assertSame(CustomCollection::class, $class->getAssociationCollectionClass('referenceMany'));
}

/**
* @param ClassMetadata<AbstractMappingDriverUser> $class
*
Expand Down
51 changes: 51 additions & 0 deletions tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
use Documents\User;
use Documents\UserName;
use Documents\UserRepository;
use Documents74\CustomCollection;
use Documents74\TypedEmbeddedDocument;
use Documents74\UserTyped;
use Generator;
use InvalidArgumentException;
use ProxyManager\Proxy\GhostObjectInterface;
Expand Down Expand Up @@ -136,6 +139,54 @@ public function testFieldIsNullable(): void
$this->assertFalse($cm->isNullable('name'), 'By default a field should not be nullable.');
}

/**
* @requires PHP >= 7.4
*/
public function testFieldTypeFromReflection(): void
{
$cm = new ClassMetadata(UserTyped::class);

// String
$cm->mapField(['fieldName' => 'username', 'length' => 50]);
self::assertEquals(Type::STRING, $cm->getTypeOfField('username'));

// DateTime object
$cm->mapField(['fieldName' => 'dateTime']);
self::assertEquals(Type::DATE, $cm->getTypeOfField('dateTime'));

// DateTimeImmutable object
$cm->mapField(['fieldName' => 'dateTimeImmutable']);
self::assertEquals(Type::DATE_IMMUTABLE, $cm->getTypeOfField('dateTimeImmutable'));

// array as hash
$cm->mapField(['fieldName' => 'array']);
self::assertEquals(Type::HASH, $cm->getTypeOfField('array'));

// bool
$cm->mapField(['fieldName' => 'boolean']);
self::assertEquals(Type::BOOL, $cm->getTypeOfField('boolean'));

// float
$cm->mapField(['fieldName' => 'float']);
self::assertEquals(Type::FLOAT, $cm->getTypeOfField('float'));

// int
$cm->mapField(['fieldName' => 'int']);
self::assertEquals(Type::INT, $cm->getTypeOfField('int'));

$cm->mapOneEmbedded(['fieldName' => 'embedOne']);
self::assertEquals(TypedEmbeddedDocument::class, $cm->getAssociationTargetClass('embedOne'));

$cm->mapOneReference(['fieldName' => 'referenceOne']);
self::assertEquals(UserTyped::class, $cm->getAssociationTargetClass('referenceOne'));

$cm->mapManyEmbedded(['fieldName' => 'embedMany']);
self::assertEquals(CustomCollection::class, $cm->getAssociationCollectionClass('embedMany'));

$cm->mapManyReference(['fieldName' => 'referenceMany']);
self::assertEquals(CustomCollection::class, $cm->getAssociationCollectionClass('referenceMany'));
}

/**
* @group DDC-115
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>

<doctrine-mongo-mapping xmlns="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping
http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd">

<document name="Documents74\UserTyped">
<id field-name="id" />

<field name="username"/>
<field name="dateTime"/>
<field name="dateTimeImmutable"/>
<field name="array"/>
<field name="boolean"/>
<field name="float"/>
<field name="int"/>

<embed-one field="embedOne" />
<reference-one field="referenceOne" />

<embed-many field="embedMany" />
<reference-many field="referenceMany" />
</document>
</doctrine-mongo-mapping>
Loading

0 comments on commit 7276a92

Please sign in to comment.