diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst index 2c7e41cfae7..36384bbf584 100644 --- a/docs/en/reference/annotations-reference.rst +++ b/docs/en/reference/annotations-reference.rst @@ -89,7 +89,7 @@ as part of the lifecycle of the instance variables entity-class. Required attributes: - **type**: Name of the Doctrine Type which is converted between PHP - and Database representation. + and Database representation. Default to ``string`` or :ref:`Type from PHP property type ` Optional attributes: @@ -113,7 +113,7 @@ Optional attributes: - **unique**: Boolean value to determine if the value of the column should be unique across all rows of the underlying entities table. -- **nullable**: Determines if NULL values allowed for this column. If not specified, default value is false. +- **nullable**: Determines if NULL values allowed for this column. If not specified, default value is false. When using typed properties on entity class defaults to true when property is nullable. - **options**: Array of additional options: @@ -635,6 +635,8 @@ Optional attributes: constraint level. Defaults to false. - **nullable**: Determine whether the related entity is required, or if null is an allowed state for the relation. Defaults to true. + When using typed properties on entity class defaults to false when + property is not nullable. - **onDelete**: Cascade Action (Database-level) - **columnDefinition**: DDL SQL snippet that starts after the column name and specifies the complete (non-portable!) column definition. @@ -715,6 +717,7 @@ Required attributes: - **targetEntity**: FQCN of the referenced target entity. Can be the unqualified class name if both classes are in the same namespace. + You can omit this value if you use a PHP property type instead. *IMPORTANT:* No leading backslash! Optional attributes: @@ -923,6 +926,7 @@ Required attributes: - **targetEntity**: FQCN of the referenced target entity. Can be the unqualified class name if both classes are in the same namespace. + When typed properties are used it is inherited from PHP type. *IMPORTANT:* No leading backslash! Optional attributes: diff --git a/docs/en/reference/association-mapping.rst b/docs/en/reference/association-mapping.rst index 946ae7dc4da..ea3a8f11dc2 100644 --- a/docs/en/reference/association-mapping.rst +++ b/docs/en/reference/association-mapping.rst @@ -22,9 +22,9 @@ One tip for working with relations is to read the relation from left to right, w - ManyToOne - Many instances of the current Entity refer to One instance of the referred Entity. - OneToOne - One instance of the current Entity refers to One instance of the referred Entity. -See below for all the possible relations. +See below for all the possible relations. -An association is considered to be unidirectional if only one side of the association has +An association is considered to be unidirectional if only one side of the association has a property referring to the other side. To gain a full understanding of associations you should also read about :doc:`owning and @@ -1061,6 +1061,70 @@ join columns default to the simple, unqualified class name of the targeted class followed by "\_id". The referencedColumnName always defaults to "id", just as in one-to-one or many-to-one mappings. +Additionally, when using typed properties with Doctrine 2.9 or newer +you can skip ``targetEntity`` in ``ManyToOne`` and ``OneToOne`` +associations as they will be set based on type. Also ``nullable`` +attribute on ``JoinColumn`` will be inherited from PHP type. So that: + +.. configuration-block:: + + .. code-block:: php + + + + + + + + .. code-block:: yaml + + Product: + type: entity + oneToOne: + shipment: ~ + +Is essentially the same as following: + +.. configuration-block:: + + .. code-block:: php + + + + + + + + + + .. code-block:: yaml + + Product: + type: entity + oneToOne: + shipment: + targetEntity: Shipment + joinColumn: + name: shipment_id + referencedColumnName: id + nullable: false + If you accept these defaults, you can reduce the mapping code to a minimum. diff --git a/docs/en/reference/basic-mapping.rst b/docs/en/reference/basic-mapping.rst index 3ccea7ea024..57b10bf00e2 100644 --- a/docs/en/reference/basic-mapping.rst +++ b/docs/en/reference/basic-mapping.rst @@ -211,6 +211,25 @@ list: - ``options``: (optional) Key-value pairs of options that get passed to the underlying database platform when generating DDL statements. +.. _reference-php-mapping-types: + +PHP Types Mapping +_________________ + +Since version 2.9 Doctrine can determine usable defaults from property types +on entity classes. When property type is nullable the default for ``nullable`` +Column attribute is set to TRUE. Additionally, Doctrine will map PHP types +to ``type`` attribute as follows: + +- ``DateInterval``: ``dateinterval`` +- ``DateTime``: ``datetime`` +- ``DateTimeImmutable``: ``datetime_immutable`` +- ``array``: ``json`` +- ``bool``: ``boolean`` +- ``float``: ``float`` +- ``int``: ``integer`` +- ``string`` or any other type: ``string`` + .. _reference-mapping-types: Doctrine Mapping Types @@ -328,7 +347,7 @@ annotation. In most cases using the automatic generator strategy (``@GeneratedValue``) is what you want. It defaults to the identifier generation mechanism your current -database vendor prefers: AUTO_INCREMENT with MySQL, sequences with PostgreSQL +database vendor prefers: AUTO_INCREMENT with MySQL, sequences with PostgreSQL and Oracle and so on. Identifier Generation Strategies diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index f89571bbd07..e7e32890737 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -21,8 +21,12 @@ namespace Doctrine\ORM\Mapping; use BadMethodCallException; +use DateInterval; +use DateTime; +use DateTimeImmutable; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\Types; use Doctrine\Instantiator\Instantiator; use Doctrine\Instantiator\InstantiatorInterface; use Doctrine\ORM\Cache\CacheException; @@ -31,6 +35,7 @@ use Doctrine\Persistence\Mapping\ReflectionService; use InvalidArgumentException; use ReflectionClass; +use ReflectionNamedType; use ReflectionProperty; use RuntimeException; @@ -59,6 +64,8 @@ use function trait_exists; use function trim; +use const PHP_VERSION_ID; + /** * A ClassMetadata instance holds all the object-relational mapping metadata * of an entity and its associations. @@ -1403,22 +1410,121 @@ public function getSqlResultSetMappings() return $this->sqlResultSetMappings; } + /** + * Checks whether given property has type + * + * @param string $name Property name + */ + private function isTypedProperty(string $name): bool + { + return PHP_VERSION_ID >= 70400 + && isset($this->reflClass) + && $this->reflClass->hasProperty($name) + && $this->reflClass->getProperty($name)->hasType(); + } + + /** + * Validates & completes the given field mapping based on typed property. + * + * @param mixed[] $mapping The field mapping to validate & complete. + * + * @return mixed[] The updated mapping. + */ + private function validateAndCompleteTypedFieldMapping(array $mapping): array + { + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); + + if ($type) { + if (! isset($mapping['nullable'])) { + $mapping['nullable'] = $type->allowsNull(); + } + + if ( + ! isset($mapping['type']) + && ($type instanceof ReflectionNamedType) + ) { + switch ($type->getName()) { + case DateInterval::class: + $mapping['type'] = Types::DATEINTERVAL; + break; + case DateTime::class: + $mapping['type'] = Types::DATETIME_MUTABLE; + break; + case DateTimeImmutable::class: + $mapping['type'] = Types::DATETIME_IMMUTABLE; + break; + case 'array': + $mapping['type'] = Types::JSON; + break; + case 'bool': + $mapping['type'] = Types::BOOLEAN; + break; + case 'float': + $mapping['type'] = Types::FLOAT; + break; + case 'int': + $mapping['type'] = Types::INTEGER; + break; + case 'string': + $mapping['type'] = Types::STRING; + break; + } + } + } + + return $mapping; + } + + /** + * Validates & completes the basic mapping information based on typed property. + * + * @param mixed[] $mapping The mapping. + * + * @return mixed[] The updated mapping. + */ + private function validateAndCompleteTypedAssociationMapping(array $mapping): array + { + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); + + if ( + ! isset($mapping['targetEntity']) + && ($mapping['type'] & self::TO_ONE) > 0 + && $type instanceof ReflectionNamedType + ) { + $mapping['targetEntity'] = $type->getName(); + } + + if ($type !== null && isset($mapping['joinColumns'])) { + foreach ($mapping['joinColumns'] as &$joinColumn) { + if (! isset($joinColumn['nullable'])) { + $joinColumn['nullable'] = $type->allowsNull(); + } + } + } + + return $mapping; + } + /** * Validates & completes the given field mapping. * * @param array $mapping The field mapping to validate & complete. * - * @return void + * @return mixed[] The updated mapping. * * @throws MappingException */ - protected function _validateAndCompleteFieldMapping(array &$mapping) + protected function validateAndCompleteFieldMapping(array $mapping): array { // Check mandatory fields if (! isset($mapping['fieldName']) || ! $mapping['fieldName']) { throw MappingException::missingFieldName($this->name); } + if ($this->isTypedProperty($mapping['fieldName'])) { + $mapping = $this->validateAndCompleteTypedFieldMapping($mapping); + } + if (! isset($mapping['type'])) { // Default to string $mapping['type'] = 'string'; @@ -1465,6 +1571,8 @@ protected function _validateAndCompleteFieldMapping(array &$mapping) $mapping['requireSQLConversion'] = true; } + + return $mapping; } /** @@ -1516,6 +1624,10 @@ protected function _validateAndCompleteAssociationMapping(array $mapping) // the sourceEntity. $mapping['sourceEntity'] = $this->name; + if ($this->isTypedProperty($mapping['fieldName'])) { + $mapping = $this->validateAndCompleteTypedAssociationMapping($mapping); + } + if (isset($mapping['targetEntity'])) { $mapping['targetEntity'] = $this->fullyQualifiedClassName($mapping['targetEntity']); $mapping['targetEntity'] = ltrim($mapping['targetEntity'], '\\'); @@ -2305,7 +2417,7 @@ public function setAttributeOverride($fieldName, array $overrideMapping) unset($this->fieldNames[$mapping['columnName']]); unset($this->columnNames[$mapping['fieldName']]); - $this->_validateAndCompleteFieldMapping($overrideMapping); + $overrideMapping = $this->validateAndCompleteFieldMapping($overrideMapping); $this->fieldMappings[$fieldName] = $overrideMapping; } @@ -2443,7 +2555,7 @@ private function _isInheritanceType($type) */ public function mapField(array $mapping) { - $this->_validateAndCompleteFieldMapping($mapping); + $mapping = $this->validateAndCompleteFieldMapping($mapping); $this->assertFieldNotMapped($mapping['fieldName']); $this->fieldMappings[$mapping['fieldName']] = $mapping; diff --git a/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php b/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php new file mode 100644 index 00000000000..82dfa9faccd --- /dev/null +++ b/tests/Doctrine/Tests/Models/TypedProperties/UserTyped.php @@ -0,0 +1,52 @@ +assertFalse($cm->isNullable('name'), 'By default a field should not be nullable.'); } + public function testFieldIsNullableByType(): void + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('requies PHP 7.4'); + } + + $cm = new ClassMetadata(TypedProperties\UserTyped::class); + $cm->initializeReflection(new RuntimeReflectionService()); + + // Explicit Nullable + $cm->mapField(['fieldName' => 'status', 'length' => 50]); + $this->assertTrue($cm->isNullable('status')); + + // Explicit Not Nullable + $cm->mapField(['fieldName' => 'username', 'length' => 50]); + $this->assertFalse($cm->isNullable('username')); + + // Join table Nullable + $cm->mapOneToOne(['fieldName' => 'email', 'joinColumns' => [[]]]); + $this->assertFalse($cm->getAssociationMapping('email')['joinColumns'][0]['nullable']); + $this->assertEquals(CmsEmail::class, $cm->getAssociationMapping('email')['targetEntity']); + } + + public function testFieldTypeFromReflection(): void + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('requies PHP 7.4'); + } + + $cm = new ClassMetadata(TypedProperties\UserTyped::class); + $cm->initializeReflection(new RuntimeReflectionService()); + + // Integer + $cm->mapField(['fieldName' => 'id']); + $this->assertEquals('integer', $cm->getTypeOfField('id')); + + // String + $cm->mapField(['fieldName' => 'username', 'length' => 50]); + $this->assertEquals('string', $cm->getTypeOfField('username')); + + // DateInterval object + $cm->mapField(['fieldName' => 'dateInterval']); + $this->assertEquals('dateinterval', $cm->getTypeOfField('dateInterval')); + + // DateTime object + $cm->mapField(['fieldName' => 'dateTime']); + $this->assertEquals('datetime', $cm->getTypeOfField('dateTime')); + + // DateTimeImmutable object + $cm->mapField(['fieldName' => 'dateTimeImmutable']); + $this->assertEquals('datetime_immutable', $cm->getTypeOfField('dateTimeImmutable')); + + // array as JSON + $cm->mapField(['fieldName' => 'array']); + $this->assertEquals('json', $cm->getTypeOfField('array')); + + // bool + $cm->mapField(['fieldName' => 'boolean']); + $this->assertEquals('boolean', $cm->getTypeOfField('boolean')); + + // float + $cm->mapField(['fieldName' => 'float']); + $this->assertEquals('float', $cm->getTypeOfField('float')); + } + /** * @group DDC-115 */