From 1795e6116147c0a91739d5d49a9965e96a7b0a68 Mon Sep 17 00:00:00 2001 From: Rixafy <45132928+Rixafy@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:32:28 +0100 Subject: [PATCH] Add support for inherited nullability from PHP --- docs/en/reference/advanced-configuration.rst | 18 ++++ docs/en/reference/attributes-reference.rst | 4 +- docs/en/reference/xml-mapping.rst | 3 +- phpstan-baseline.neon | 2 +- src/Configuration.php | 10 ++ src/Mapping/ClassMetadata.php | 40 ++++++-- src/Mapping/ClassMetadataFactory.php | 1 + src/Mapping/Column.php | 7 +- src/Mapping/Driver/AttributeDriver.php | 16 +-- src/Mapping/JoinColumnProperties.php | 7 +- src/ORMSetup.php | 2 +- .../Tests/Models/TypedProperties/Contact.php | 6 ++ .../Models/TypedProperties/UserTyped.php | 84 +++++++++++++++- .../ORM/Mapping/MappingDriverTestCase.php | 97 ++++++++++++++++++- ...sts.Models.TypedProperties.Contact.dcm.xml | 11 +++ ...s.Models.TypedProperties.UserTyped.dcm.xml | 21 +++- 16 files changed, 302 insertions(+), 27 deletions(-) create mode 100644 tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml diff --git a/docs/en/reference/advanced-configuration.rst b/docs/en/reference/advanced-configuration.rst index 3282bbdb359..ee6c859aefa 100644 --- a/docs/en/reference/advanced-configuration.rst +++ b/docs/en/reference/advanced-configuration.rst @@ -184,6 +184,24 @@ For development you should use an array cache like ``Symfony\Component\Cache\Adapter\ArrayAdapter`` which only caches data on a per-request basis. +Nullability detection (***RECOMMENDED***) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: + + Since ORM 3.4.0 + +.. code-block:: php + + setInferPhpNullability(true); + +Sets whether Doctrine should infer the nullability of PHP types to the +database schema. This is useful when using PHP 7.4+ typed properties + +You can always override the inferred nullability by specifying the +``nullable`` option in the Column or JoinColumn definition. + SQL Logger (***Optional***) ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index d3a1eb04674..14505b1390f 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -176,7 +176,8 @@ 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, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - **insertable**: Boolean value to determine if the column should be included when inserting a new row into the underlying entities table. @@ -674,6 +675,7 @@ 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, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - **onDelete**: Cascade Action (Database-level) - **columnDefinition**: DDL SQL snippet that starts after the column name and specifies the complete (non-portable!) column definition. diff --git a/docs/en/reference/xml-mapping.rst b/docs/en/reference/xml-mapping.rst index e010c93690b..b00d7f975bb 100644 --- a/docs/en/reference/xml-mapping.rst +++ b/docs/en/reference/xml-mapping.rst @@ -256,7 +256,7 @@ Optional attributes: - unique - Should this field contain a unique value across the table? Defaults to false. - nullable - Should this field allow NULL as a value? Defaults to - false. + false. Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - insertable - Should this field be inserted? Defaults to true. - updatable - Should this field be updated? Defaults to true. - generated - Enum of the values ALWAYS, INSERT, NEVER that determines if @@ -717,6 +717,7 @@ Optional attributes: This makes sense for Many-To-Many join-columns only to simulate a one-to-many unidirectional using a join-table. - nullable - should the join column be nullable, defaults to true. + Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - on-delete - Foreign Key Cascade action to perform when entity is deleted, defaults to NO ACTION/RESTRICT but can be set to "CASCADE". diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 44cb4153e21..cf80738f6b1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -991,7 +991,7 @@ parameters: path: src/Mapping/ClassMetadata.php - - message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string\}, non\-empty\-array\ given\.$#' + message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string, joinColumns\: array\\>\|null\}, non\-empty\-array\ given\.$#' identifier: argument.type count: 1 path: src/Mapping/ClassMetadata.php diff --git a/src/Configuration.php b/src/Configuration.php index 361d146a50b..7614267d79c 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -644,4 +644,14 @@ public function getEagerFetchBatchSize(): int { return $this->attributes['fetchModeSubselectBatchSize'] ?? 100; } + + public function setInferPhpNullability(bool $inferPhpNullability): void + { + $this->attributes['inferPhpNullability'] = $inferPhpNullability; + } + + public function isPhpNullabilityInferred(): bool + { + return $this->attributes['inferPhpNullability'] ?? false; + } } diff --git a/src/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php index 7351d09bce0..69d17223842 100644 --- a/src/Mapping/ClassMetadata.php +++ b/src/Mapping/ClassMetadata.php @@ -22,6 +22,7 @@ use ReflectionClass; use ReflectionNamedType; use ReflectionProperty; +use ReflectionType; use Stringable; use function array_column; @@ -556,7 +557,7 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable * @param string $name The name of the entity class the new instance is used for. * @phpstan-param class-string $name */ - public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null) + public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, public readonly bool $inferPhpNullability = false) { $this->rootEntityName = $name; $this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy(); @@ -1124,14 +1125,12 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array /** * Validates & completes the basic mapping information based on typed property. * - * @param array{type: self::ONE_TO_ONE|self::MANY_TO_ONE|self::ONE_TO_MANY|self::MANY_TO_MANY, fieldName: string, targetEntity?: class-string} $mapping The mapping. + * @param array{type: self::ONE_TO_ONE|self::MANY_TO_ONE|self::ONE_TO_MANY|self::MANY_TO_MANY, fieldName: string, targetEntity?: class-string, joinColumns: array>|null} $mapping The mapping. * * @return mixed[] The updated mapping. */ - private function validateAndCompleteTypedAssociationMapping(array $mapping): array + private function validateAndCompleteTypedAssociationMapping(array $mapping, ReflectionType|null $type): array { - $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); - if ($type === null || ($mapping['type'] & self::TO_ONE) === 0) { return $mapping; } @@ -1152,6 +1151,7 @@ private function validateAndCompleteTypedAssociationMapping(array $mapping): arr * id?: bool, * generated?: self::GENERATED_*, * enumType?: class-string, + * nullable?: bool|null, * } $mapping The field mapping to validate & complete. * * @return FieldMapping The updated mapping. @@ -1165,10 +1165,17 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping throw MappingException::missingFieldName($this->name); } + $type = null; if ($this->isTypedProperty($mapping['fieldName'])) { + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); $mapping = $this->validateAndCompleteTypedFieldMapping($mapping); } + // Infer nullable from type or reset null back to true if type is missing + if ($this->inferPhpNullability && ! isset($mapping['nullable'])) { + $mapping['nullable'] = $type?->allowsNull() ?? false; + } + if (! isset($mapping['type'])) { // Default to string $mapping['type'] = 'string'; @@ -1276,8 +1283,29 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc // the sourceEntity. $mapping['sourceEntity'] = $this->name; + $type = null; if ($this->isTypedProperty($mapping['fieldName'])) { - $mapping = $this->validateAndCompleteTypedAssociationMapping($mapping); + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); + $mapping = $this->validateAndCompleteTypedAssociationMapping($mapping, $type); + } + + // Infer nullable from type or reset null back to true if type is missing + if ($this->inferPhpNullability && $mapping['type'] & self::TO_ONE) { + if (! empty($mapping['joinColumns'])) { + foreach ($mapping['joinColumns'] as $key => $data) { + if (! isset($data['nullable'])) { + $mapping['joinColumns'][$key]['nullable'] = $type?->allowsNull() ?? true; + } + } + } elseif ($type !== null) { + $mapping['joinColumns'] = [ + [ + 'fieldName' => $mapping['fieldName'], + 'nullable' => $type->allowsNull(), + 'referencedColumnName' => $this->namingStrategy->referenceColumnName(), + ], + ]; + } } if (isset($mapping['targetEntity'])) { diff --git a/src/Mapping/ClassMetadataFactory.php b/src/Mapping/ClassMetadataFactory.php index b29f20c67b1..978bcd0b28c 100644 --- a/src/Mapping/ClassMetadataFactory.php +++ b/src/Mapping/ClassMetadataFactory.php @@ -304,6 +304,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata $className, $this->em->getConfiguration()->getNamingStrategy(), $this->em->getConfiguration()->getTypedFieldMapper(), + $this->em->getConfiguration()->isPhpNullabilityInferred(), ); } diff --git a/src/Mapping/Column.php b/src/Mapping/Column.php index e044f5e3144..9da0eb1c9a0 100644 --- a/src/Mapping/Column.php +++ b/src/Mapping/Column.php @@ -10,6 +10,9 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final class Column implements MappingAttribute { + public readonly bool $nullable; + public 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). @@ -24,7 +27,7 @@ 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, + bool|null $nullable = null, public readonly bool $insertable = true, public readonly bool $updatable = true, public readonly string|null $enumType = null, @@ -32,5 +35,7 @@ public function __construct( public readonly string|null $columnDefinition = null, public readonly string|null $generated = null, ) { + $this->nullable = $nullable ?? false; + $this->nullableSet = $nullable !== null; } } diff --git a/src/Mapping/Driver/AttributeDriver.php b/src/Mapping/Driver/AttributeDriver.php index e337d60ea55..969126ee3e4 100644 --- a/src/Mapping/Driver/AttributeDriver.php +++ b/src/Mapping/Driver/AttributeDriver.php @@ -297,7 +297,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, $metadata->inferPhpNullability); } // Field can only be attributed with one of: @@ -310,7 +310,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class); if ($columnAttribute !== null) { - $mapping = $this->columnToArray($property->name, $columnAttribute); + $mapping = $this->columnToArray($property->name, $columnAttribute, $metadata->inferPhpNullability); if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) { $mapping['id'] = true; @@ -530,7 +530,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $attributeOverridesAnnot = $classAttributes[Mapping\AttributeOverrides::class]; foreach ($attributeOverridesAnnot->overrides as $attributeOverride) { - $mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column); + $mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column, $metadata->inferPhpNullability); $metadata->setAttributeOverride($attributeOverride->name, $mapping); } @@ -680,12 +680,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array * options?: array * } */ - private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array + private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferPhpNullability = false): array { $mapping = [ 'name' => $joinColumn->name, 'unique' => $joinColumn->unique, - 'nullable' => $joinColumn->nullable, + 'nullable' => $inferPhpNullability && ! $joinColumn->nullableSet ? null : $joinColumn->nullable, 'onDelete' => $joinColumn->onDelete, 'columnDefinition' => $joinColumn->columnDefinition, 'referencedColumnName' => $joinColumn->referencedColumnName, @@ -708,7 +708,7 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn * scale: int, * length: int, * unique: bool, - * nullable: bool, + * nullable: bool|null, * precision: int, * enumType?: class-string, * options?: mixed[], @@ -716,7 +716,7 @@ 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, bool $inferPhpNullability = false): array { $mapping = [ 'fieldName' => $fieldName, @@ -724,7 +724,7 @@ private function columnToArray(string $fieldName, Mapping\Column $column): array 'scale' => $column->scale, 'length' => $column->length, 'unique' => $column->unique, - 'nullable' => $column->nullable, + 'nullable' => $inferPhpNullability && ! $column->nullableSet ? null : $column->nullable, 'precision' => $column->precision, ]; diff --git a/src/Mapping/JoinColumnProperties.php b/src/Mapping/JoinColumnProperties.php index b231d834d79..20403a0ce94 100644 --- a/src/Mapping/JoinColumnProperties.php +++ b/src/Mapping/JoinColumnProperties.php @@ -6,16 +6,21 @@ trait JoinColumnProperties { + public readonly bool $nullable; + public readonly bool $nullableSet; + /** @param array $options */ public function __construct( public readonly string|null $name = null, public readonly string|null $referencedColumnName = null, public readonly bool $unique = false, - public readonly bool $nullable = true, + 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->nullable = $nullable ?? true; + $this->nullableSet = $nullable !== null; } } diff --git a/src/ORMSetup.php b/src/ORMSetup.php index 7354c710fe9..e17680c617d 100644 --- a/src/ORMSetup.php +++ b/src/ORMSetup.php @@ -34,7 +34,7 @@ public static function createAttributeMetadataConfiguration( CacheItemPoolInterface|null $cache = null, ): Configuration { $config = self::createConfiguration($isDevMode, $proxyDir, $cache); - $config->setMetadataDriverImpl(new AttributeDriver($paths)); + $config->setMetadataDriverImpl(new AttributeDriver($paths, true)); return $config; } diff --git a/tests/Tests/Models/TypedProperties/Contact.php b/tests/Tests/Models/TypedProperties/Contact.php index 0229cec95c9..6b33ef357d2 100644 --- a/tests/Tests/Models/TypedProperties/Contact.php +++ b/tests/Tests/Models/TypedProperties/Contact.php @@ -5,10 +5,16 @@ namespace Doctrine\Tests\Models\TypedProperties; use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\ClassMetadata; #[ORM\Embeddable] class Contact { #[ORM\Column] public string|null $email = null; + + public static function loadMetadata(ClassMetadata $metadata): void + { + $metadata->mapField(['fieldName' => 'email', 'type' => 'string']); + } } diff --git a/tests/Tests/Models/TypedProperties/UserTyped.php b/tests/Tests/Models/TypedProperties/UserTyped.php index 22ac2127ac2..06b28f98ce2 100644 --- a/tests/Tests/Models/TypedProperties/UserTyped.php +++ b/tests/Tests/Models/TypedProperties/UserTyped.php @@ -26,6 +26,12 @@ class UserTyped #[ORM\Column(length: 255, unique: true)] public string $username; + #[ORM\Column(nullable: true)] + public string $firstName; + + #[ORM\Column(nullable: false)] + public string|null $lastName = null; + #[ORM\Column] public DateInterval $dateInterval; @@ -48,8 +54,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] - public CmsEmail|null $mainEmail = null; + #[ORM\JoinColumn(nullable: true)] + public CmsEmail $mainEmailOverride; + + #[ORM\ManyToOne] + public CmsEmail|null $mainEmailWithNoJoinColumn = null; #[ORM\Embedded] public Contact|null $contact = null; @@ -75,6 +96,7 @@ public static function loadMetadata(ClassMetadata $metadata): void 'length' => 50, ], ); + $metadata->mapField( [ 'fieldName' => 'username', @@ -82,6 +104,21 @@ public static function loadMetadata(ClassMetadata $metadata): void 'unique' => true, ], ); + + $metadata->mapField( + [ + 'fieldName' => 'firstName', + 'nullable' => true, + ], + ); + + $metadata->mapField( + [ + 'fieldName' => 'lastName', + 'nullable' => false, + ], + ); + $metadata->mapField( ['fieldName' => 'dateInterval'], ); @@ -115,8 +152,51 @@ public static function loadMetadata(ClassMetadata $metadata): void ], ); + $metadata->mapOneToOne( + ['fieldName' => 'emailWithNoJoinColumn'], + ); + + $metadata->mapOneToOne( + [ + 'fieldName' => 'emailOverride', + 'joinColumns' => + [ + 0 => + [ + 'referencedColumnName' => 'id', + 'nullable' => false, + ], + ], + ], + ); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'mainEmail', + 'joinColumns' => + [ + 0 => + ['referencedColumnName' => 'id'], + ], + ], + ); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'mainEmailOverride', + 'joinColumns' => + [ + 0 => + [ + 'referencedColumnName' => 'id', + 'nullable' => true, + ], + ], + ], + ); + $metadata->mapManyToOne( - ['fieldName' => 'mainEmail'], + ['fieldName' => 'mainEmailWithNoJoinColumn'], ); $metadata->mapEmbedded(['fieldName' => 'contact']); diff --git a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php index 33b409f7f57..2ce0cec5853 100644 --- a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php +++ b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php @@ -83,20 +83,21 @@ public function createClassMetadata( string $entityClassName, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, + bool $inferPhpNullability = false, ): ClassMetadata { $mappingDriver = $this->loadDriver(); - $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper); + $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper, $inferPhpNullability); $class->initializeReflection(new RuntimeReflectionService()); $mappingDriver->loadMetadataForClass($entityClassName, $class); 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); @@ -957,6 +958,94 @@ public function testCustomNamingStrategyIsRespected(): void self::assertEquals('Id', $metadata->associationMappings['blogPost']->joinColumns[0]->referencedColumnName); self::assertFalse($metadata->associationMappings['blogPost']->joinColumns[0]->nullable); } + + public function testWithInferredNullability(): void + { + $untyped = $this->createClassMetadata(User::class, inferPhpNullability: true); + + // Explicit with missing type + $this->assertTrue($untyped->isNullable('name')); + + // Default with missing type + $this->assertFalse($untyped->isNullable('email')); + + // Default with missing type + $addressMapping = $untyped->getAssociationMapping('address'); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $addressMapping); + $this->assertTrue($addressMapping->joinColumns[0]->nullable); + + $class = $this->createClassMetadata(UserTyped::class, inferPhpNullability: true); + + // Defers to PHP type + $this->assertTrue($class->isNullable('status')); + + // Defers to PHP type + $this->assertFalse($class->isNullable('username')); + + // Override nullable by definition + $this->assertTrue($class->isNullable('firstName')); + $this->assertFalse($class->isNullable('lastName')); + + // 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 testWithoutInferredNullability(): void + { + $class = $this->createClassMetadata(UserTyped::class); + + // Default + $this->assertFalse($class->isNullable('status')); + + // Default + $this->assertFalse($class->isNullable('username')); + + // Explicit + $this->assertTrue($class->isNullable('firstName')); + $this->assertFalse($class->isNullable('lastName')); + + // Nullables by definition + foreach (['email', 'mainEmail', 'mainEmailOverride'] as $value) { + $emailMapping = $class->getAssociationMapping($value); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + + // XML and StaticPHP Driver can contain null if joinColumn is not defined + $this->assertNotFalse($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()] diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml new file mode 100644 index 00000000000..49995d1f76b --- /dev/null +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml index a9547af4ab3..0b6d22c4f45 100644 --- a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml @@ -12,6 +12,8 @@ + + @@ -24,7 +26,24 @@ - + + + + + + + + + + + + + + + + + +