Skip to content

Commit

Permalink
Use typed properties for default metadata for #7939 (#8439)
Browse files Browse the repository at this point in the history
[GH-7939] Detect column and association types from typed properties.

* Use typed properties for default metadata for #7939

* Coding Standards

* Remove $name from CmsUserTypes and adapt tests

* Factor out conditions required for typed property

* Factor out typed validation and completion methods

* Move Typed tests model to separate namespace

* Don't pass by reference, return array

* Document changes to default mapping for typed properties

* Better wording in annotation reference

* Add missing targetEntity assertion on typed association

* Try to comply with CS

* USe constants instead of strings

* Use one-line comments for single line content

* phpcs

* phpcs

Co-authored-by: Benjamin Eberlei <[email protected]>
  • Loading branch information
Lustmored and beberlei authored Mar 1, 2021
1 parent 4cdc6b1 commit b3ed525
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 9 deletions.
8 changes: 6 additions & 2 deletions docs/en/reference/annotations-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reference-php-mapping-types>`

Optional attributes:

Expand All @@ -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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
68 changes: 66 additions & 2 deletions docs/en/reference/association-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<?php
/** @OneToOne */
private Shipment $shipment;
.. code-block:: xml
<doctrine-mapping>
<entity class="Product">
<one-to-one field="shipment" />
</entity>
</doctrine-mapping>
.. code-block:: yaml
Product:
type: entity
oneToOne:
shipment: ~
Is essentially the same as following:

.. configuration-block::

.. code-block:: php
<?php
/**
* One Product has One Shipment.
* @OneToOne(targetEntity="Shipment")
* @JoinColumn(name="shipment_id", referencedColumnName="id", nullable=false)
*/
private Shipment $shipment;
.. code-block:: xml
<doctrine-mapping>
<entity class="Product">
<one-to-one field="shipment" target-entity="Shipment">
<join-column name="shipment_id" referenced-column-name="id" nulable=false />
</one-to-one>
</entity>
</doctrine-mapping>
.. 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.

Expand Down
21 changes: 20 additions & 1 deletion docs/en/reference/basic-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 116 additions & 4 deletions lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +35,7 @@
use Doctrine\Persistence\Mapping\ReflectionService;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;
use RuntimeException;

Expand Down Expand Up @@ -59,6 +64,8 @@
use function trait_exists;
use function trim;

use const PHP_VERSION_ID;

/**
* A <tt>ClassMetadata</tt> instance holds all the object-relational mapping metadata
* of an entity and its associations.
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1465,6 +1571,8 @@ protected function _validateAndCompleteFieldMapping(array &$mapping)

$mapping['requireSQLConversion'] = true;
}

return $mapping;
}

/**
Expand Down Expand Up @@ -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'], '\\');
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit b3ed525

Please sign in to comment.