diff --git a/UPGRADE-2.4.md b/UPGRADE-2.4.md new file mode 100644 index 0000000000..a5dcd9e316 --- /dev/null +++ b/UPGRADE-2.4.md @@ -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. diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst index 650792e762..ae7d8daa14 100644 --- a/docs/en/reference/annotations-reference.rst +++ b/docs/en/reference/annotations-reference.rst @@ -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, @@ -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. @@ -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 `. - ``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. @@ -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 @@ -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 diff --git a/docs/en/reference/basic-mapping.rst b/docs/en/reference/basic-mapping.rst index 8912a18fa7..b53e66e6e6 100644 --- a/docs/en/reference/basic-mapping.rst +++ b/docs/en/reference/basic-mapping.rst @@ -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 ---------------- diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/AbstractField.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/AbstractField.php index 2fbb7b72df..96ce2a825d 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/AbstractField.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/AbstractField.php @@ -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, diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index c8546cbb7a..7e9dcb2fb6 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -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; @@ -20,6 +22,7 @@ use LogicException; use ProxyManager\Proxy\GhostObjectInterface; use ReflectionClass; +use ReflectionNamedType; use ReflectionProperty; use function array_filter; @@ -43,6 +46,8 @@ use function strtoupper; use function trigger_deprecation; +use const PHP_VERSION_ID; + /** * A ClassMetadata instance holds all the object-document mapping metadata * of a document and it's references. @@ -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); } @@ -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); } @@ -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; } @@ -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; + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php index 9e7fe3698d..dfb3a3162c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php @@ -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; @@ -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 $class * diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index 023a86a248..46b6edba20 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -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; @@ -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 */ diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Documents74.UserTyped.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Documents74.UserTyped.dcm.xml new file mode 100644 index 0000000000..88ee102bf1 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Documents74.UserTyped.dcm.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Documents74/CustomCollection.php b/tests/Documents74/CustomCollection.php new file mode 100644 index 0000000000..2417f0986f --- /dev/null +++ b/tests/Documents74/CustomCollection.php @@ -0,0 +1,16 @@ + + */ +class CustomCollection extends ArrayCollection +{ +} diff --git a/tests/Documents74/UserTyped.php b/tests/Documents74/UserTyped.php new file mode 100644 index 0000000000..612c41fec0 --- /dev/null +++ b/tests/Documents74/UserTyped.php @@ -0,0 +1,64 @@ +