Skip to content

Commit

Permalink
Add support for inherited nullability from PHP
Browse files Browse the repository at this point in the history
  • Loading branch information
Rixafy committed Jan 30, 2025
1 parent aff82af commit 5aec3f6
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 18 deletions.
6 changes: 5 additions & 1 deletion docs/en/reference/attributes-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ Optional parameters:
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``.
If not specified, default value is ``false``.
Since ORM 3.4, value can be inherited from PHP type when ``ORMSetup::createAttributeMetadataConfiguration()``
or creation of ``AttributeDriver`` has ``$inheritNullabilityFromPropertyType`` set to ``true``.

- **insertable**: Boolean value to determine if the column should be
included when inserting a new row into the underlying entities table.
Expand Down Expand Up @@ -674,6 +676,8 @@ Optional parameters:
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.
Since ORM 3.4, value can be inherited from PHP type when ``ORMSetup::createAttributeMetadataConfiguration()``
or creation of ``AttributeDriver`` has ``$inheritNullabilityFromPropertyType`` set to ``true``.
- **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
16 changes: 15 additions & 1 deletion src/Mapping/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Column implements MappingAttribute
{
private readonly bool $nullableSet;

/**
* @param int|null $precision The precision for a decimal (exact numeric) column (Applies only for decimal column).
* @param int|null $scale The scale for a decimal (exact numeric) column (Applies only for decimal column).
Expand All @@ -24,13 +26,25 @@ public function __construct(
public readonly int|null $precision = null,
public readonly int|null $scale = null,
public readonly bool $unique = false,
public readonly bool $nullable = false,
private bool|null $nullable = null,
public readonly bool $insertable = true,
public readonly bool $updatable = true,
public readonly string|null $enumType = null,
public readonly array $options = [],
public readonly string|null $columnDefinition = null,
public readonly string|null $generated = null,
) {
$this->nullableSet = $nullable !== null;
$this->nullable ??= false;
}

public function isNullable(): bool
{
return $this->nullable;
}

public function isNullableSet(): bool
{
return $this->nullableSet;
}
}
43 changes: 35 additions & 8 deletions src/Mapping/Driver/AttributeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use InvalidArgumentException;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;

use function assert;
use function class_exists;
Expand All @@ -38,8 +39,11 @@ class AttributeDriver implements MappingDriver
* @param array<string> $paths
* @param true $reportFieldsWhereDeclared no-op, to be removed in 4.0
*/
public function __construct(array $paths, bool $reportFieldsWhereDeclared = true)
{
public function __construct(
array $paths,
bool $reportFieldsWhereDeclared = true,
private readonly bool $inheritNullabilityFromPropertyType = false,
) {
if (! $reportFieldsWhereDeclared) {
throw new InvalidArgumentException(sprintf(
'The $reportFieldsWhereDeclared argument is no longer supported, make sure to omit it when calling %s.',
Expand Down Expand Up @@ -297,7 +301,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);

foreach ($joinColumnAttributes as $joinColumnAttribute) {
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute);
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $property);
}

// Field can only be attributed with one of:
Expand All @@ -309,8 +313,17 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
$manyToManyAttribute = $this->reader->getPropertyAttribute($property, Mapping\ManyToMany::class);
$embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class);

// If the property has a type declaration, and no explicit JoinColumn attributes are set, we can infer nullability from the type declaration
if ($this->inheritNullabilityFromPropertyType && empty($joinColumns) && $property->hasType() && ($oneToOneAttribute !== null || $manyToOneAttribute !== null)) {
$joinColumns = [
[
'nullable' => $property->getType()->allowsNull(),
],
];
}

if ($columnAttribute !== null) {
$mapping = $this->columnToArray($property->name, $columnAttribute);
$mapping = $this->columnToArray($property->name, $columnAttribute, $property);

if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) {
$mapping['id'] = true;
Expand Down Expand Up @@ -680,12 +693,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array
* options?: array<string, mixed>
* }
*/
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, ReflectionProperty|null $property = null): array
{
$mapping = [
'name' => $joinColumn->name,
'unique' => $joinColumn->unique,
'nullable' => $joinColumn->nullable,
'nullable' => $this->detectNullability($property, $joinColumn->isNullable(), $joinColumn->isNullableSet()),
'onDelete' => $joinColumn->onDelete,
'columnDefinition' => $joinColumn->columnDefinition,
'referencedColumnName' => $joinColumn->referencedColumnName,
Expand Down Expand Up @@ -716,15 +729,15 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
* columnDefinition?: string
* }
*/
private function columnToArray(string $fieldName, Mapping\Column $column): array
private function columnToArray(string $fieldName, Mapping\Column $column, ReflectionProperty|null $property = null): array
{
$mapping = [
'fieldName' => $fieldName,
'type' => $column->type,
'scale' => $column->scale,
'length' => $column->length,
'unique' => $column->unique,
'nullable' => $column->nullable,
'nullable' => $this->detectNullability($property, $column->isNullable(), $column->isNullableSet()),
'precision' => $column->precision,
];

Expand Down Expand Up @@ -758,4 +771,18 @@ private function columnToArray(string $fieldName, Mapping\Column $column): array

return $mapping;
}

private function detectNullability(ReflectionProperty|null $property, bool $default, bool $wasSet): bool
{
if (
$this->inheritNullabilityFromPropertyType
&& ! $wasSet
&& $property !== null
&& $property->hasType()
) {
return $property->getType()->allowsNull();
}

return $default;
}
}
16 changes: 15 additions & 1 deletion src/Mapping/JoinColumnProperties.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,30 @@

trait JoinColumnProperties
{
private readonly bool $nullableSet;

/** @param array<string, mixed> $options */
public function __construct(
public readonly string|null $name = null,
public readonly string $referencedColumnName = 'id',
public readonly bool $unique = false,
public readonly bool $nullable = true,
private bool|null $nullable = null,
public readonly mixed $onDelete = null,
public readonly string|null $columnDefinition = null,
public readonly string|null $fieldName = null,
public readonly array $options = [],
) {
$this->nullableSet = $nullable !== null;
$this->nullable ??= true;
}

public function isNullable(): bool
{
return $this->nullable;
}

public function isNullableSet(): bool
{
return $this->nullableSet;
}
}
5 changes: 5 additions & 0 deletions src/Mapping/ToOneOwningSideMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ public static function fromMappingArrayAndName(
if (empty($joinColumn['name'])) {
$mappingArray['joinColumns'][$index]['name'] = $namingStrategy->joinColumnName($mappingArray['fieldName'], $name);
}

// Added to support JoinColumn from AnnotationDriver that has only nullable information (nullable type detection)
if (empty($joinColumn['referencedColumnName'])) {
$mappingArray['joinColumns'][$index]['referencedColumnName'] = $namingStrategy->referenceColumnName();
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/ORMSetup.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ public static function createAttributeMetadataConfiguration(
bool $isDevMode = false,
string|null $proxyDir = null,
CacheItemPoolInterface|null $cache = null,
bool $inheritNullabilityFromPropertyType = false,
): Configuration {
$config = self::createConfiguration($isDevMode, $proxyDir, $cache);
$config->setMetadataDriverImpl(new AttributeDriver($paths));
$config->setMetadataDriverImpl(new AttributeDriver($paths, true, $inheritNullabilityFromPropertyType));

return $config;
}
Expand Down
17 changes: 16 additions & 1 deletion tests/Tests/Models/TypedProperties/UserTyped.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,23 @@ class UserTyped
#[ORM\JoinColumn]
public CmsEmail $email;

#[ORM\OneToOne]
public CmsEmail|null $emailWithNoJoinColumn;

#[ORM\OneToOne]
#[ORM\JoinColumn(nullable: false)]
public CmsEmail|null $emailOverride;

#[ORM\ManyToOne]
#[ORM\JoinColumn]
public CmsEmail $mainEmail;

#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
public CmsEmail $mainEmailOverride;

#[ORM\ManyToOne]
public CmsEmail|null $mainEmail = null;
public CmsEmail|null $mainEmailWithNoJoinColumn = null;

#[ORM\Embedded]
public Contact|null $contact = null;
Expand Down
74 changes: 72 additions & 2 deletions tests/Tests/ORM/Mapping/AttributeDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@
use Doctrine\ORM\Mapping\JoinColumnMapping;
use Doctrine\ORM\Mapping\MappingAttribute;
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
use Doctrine\Tests\Models\TypedProperties\UserTyped;
use Doctrine\Tests\ORM\Mapping\Fixtures\AttributeEntityWithNestedJoinColumns;
use InvalidArgumentException;
use stdClass;

class AttributeDriverTest extends MappingDriverTestCase
{
protected function loadDriver(): MappingDriver
protected function loadDriver(bool $inheritNullabilityFromPropertyType = false): MappingDriver
{
$paths = [];

return new AttributeDriver($paths, true);
return new AttributeDriver($paths, true, $inheritNullabilityFromPropertyType);
}

public function testOriginallyNestedAttributesDeclaredWithoutOriginalParent(): void
Expand Down Expand Up @@ -95,6 +96,75 @@ public function testItThrowsWhenSettingReportFieldsWhereDeclaredToFalse(): void

new AttributeDriver([], false);
}

public function testFieldIsNullableByType(): void
{
$factory = $this->createClassMetadataFactory(driver: $this->loadDriver(true));

$class = $factory->getMetadataFor(UserTyped::class);

// Explicit Nullable
$this->assertTrue($class->isNullable('status'));

// Explicit Not Nullable
$this->assertFalse($class->isNullable('username'));

// Non-nullable by PHP type
foreach (['email', 'mainEmail', 'emailOverride'] as $value) {
$emailMapping = $class->getAssociationMapping($value);
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
$this->assertFalse($emailMapping->joinColumns[0]->nullable);
}

// Nullable by PHP type
foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) {
$emailMapping = $class->getAssociationMapping($value);
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
$this->assertTrue($emailMapping->joinColumns[0]->nullable);
}

// Override nullable by definition (true -> false)
$emailMapping = $class->getAssociationMapping('emailOverride');
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
$this->assertFalse($emailMapping->joinColumns[0]->nullable);

// Override nullable by definition (false -> true)
$emailMapping = $class->getAssociationMapping('mainEmailOverride');
$this->assertInstanceof(ORM\ManyToOneAssociationMapping::class, $emailMapping);
$this->assertTrue($emailMapping->joinColumns[0]->nullable);
}

public function testFieldIsNullableByDefinition(): void
{
$factory = $this->createClassMetadataFactory();

$class = $factory->getMetadataFor(UserTyped::class);

// Explicit Nullable
$this->assertFalse($class->isNullable('status'));

// Explicit Not Nullable
$this->assertFalse($class->isNullable('username'));

// Nullables by definition
foreach (['email', 'mainEmail', 'mainEmailOverride'] as $value) {
$emailMapping = $class->getAssociationMapping($value);
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
$this->assertTrue($emailMapping->joinColumns[0]->nullable);
}

// JoinColumn not defined
foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) {
$emailMapping = $class->getAssociationMapping($value);
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
$this->assertNull($emailMapping->joinColumns[0]->nullable);
}

// Not nullable by definition
$emailMapping = $class->getAssociationMapping('emailOverride');
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
$this->assertFalse($emailMapping->joinColumns[0]->nullable);
}
}

#[ORM\Entity]
Expand Down
6 changes: 3 additions & 3 deletions tests/Tests/ORM/Mapping/MappingDriverTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ public function createClassMetadata(
return $class;
}

protected function createClassMetadataFactory(EntityManagerInterface|null $em = null): ClassMetadataFactory
protected function createClassMetadataFactory(EntityManagerInterface|null $em = null, MappingDriver|null $driver = null): ClassMetadataFactory
{
$driver = $this->loadDriver();
$em ??= $this->getTestEntityManager();
$driver ??= $this->loadDriver();
$em ??= $this->getTestEntityManager();
$factory = new ClassMetadataFactory();
$em->getConfiguration()->setMetadataDriverImpl($driver);
$factory->setEntityManager($em);
Expand Down

0 comments on commit 5aec3f6

Please sign in to comment.