diff --git a/src/Definition/Field.php b/src/Definition/Field.php index 75d34cf..4f2a83d 100644 --- a/src/Definition/Field.php +++ b/src/Definition/Field.php @@ -35,6 +35,7 @@ final class Field private ?int $generated = null; private bool $referenced = false; + private bool $relationGenerated = false; private ?string $entityClass = null; public function __construct() @@ -161,6 +162,26 @@ public function isReferenced(): bool return $this->referenced; } + /** + * Marks the field as created by a relation rather than declared by the user. + * + * @internal + */ + public function setRelationGenerated(bool $relationGenerated): self + { + $this->relationGenerated = $relationGenerated; + + return $this; + } + + /** + * @internal + */ + public function isRelationGenerated(): bool + { + return $this->relationGenerated; + } + public function getEntityClass(): ?string { return $this->entityClass; diff --git a/src/Relation/Traits/FieldTrait.php b/src/Relation/Traits/FieldTrait.php index c7efa24..da8a4c5 100644 --- a/src/Relation/Traits/FieldTrait.php +++ b/src/Relation/Traits/FieldTrait.php @@ -136,11 +136,19 @@ protected function ensureField(Entity $target, string $fieldName, Field $outerFi $outerField->setReferenced(true); if ($target->getFields()->has($fieldName)) { - // field already exists and defined by the user + $field = $target->getFields()->get($fieldName); + + // A column shared by several relations (HasMany and its BelongsTo) must not depend + // on which relation is generated first; user-declared fields are kept as they are. + if ($nullable && $field->isRelationGenerated()) { + $field->getOptions()->set(Column::OPT_NULLABLE, true); + } + return; } $field = new Field(); + $field->setRelationGenerated(true); $field->setEntityClass($target->getClass()); $field->setColumn($fieldName); $field->setTypecast($outerField->getTypecast()); diff --git a/tests/Schema/Relation/HasManyRelationTest.php b/tests/Schema/Relation/HasManyRelationTest.php index 78910e1..8802b92 100644 --- a/tests/Schema/Relation/HasManyRelationTest.php +++ b/tests/Schema/Relation/HasManyRelationTest.php @@ -4,10 +4,14 @@ namespace Cycle\Schema\Tests\Relation; +use Cycle\Database\Schema\AbstractTable; use Cycle\ORM\Collection\ArrayCollectionFactory; use Cycle\ORM\Relation; use Cycle\ORM\Schema; use Cycle\Schema\Compiler; +use Cycle\Schema\Definition\Entity; +use Cycle\Schema\Definition\Field; +use Cycle\Schema\Definition\Relation as RelationDefinition; use Cycle\Schema\Exception\RegistryException; use Cycle\Schema\Exception\SchemaException; use Cycle\Schema\Generator\GenerateRelations; @@ -304,4 +308,70 @@ public function testRenderWithIndex(): void $table = $this->getDriver()->getSchema('plain'); $this->assertTrue($table->hasIndex(['user_p_id'])); } + + public function belongsToNullableOrders(): iterable + { + yield 'hasMany source first' => [true]; + yield 'belongsTo source first' => [false]; + } + + /** + * @dataProvider belongsToNullableOrders + */ + public function testNullableBelongsToMakesSharedColumnNullable(bool $userFirst): void + { + $e = Plain::define(); + $u = User::define(); + + $u->getRelations()->get('plain')->setType('hasMany'); + $e->getRelations()->set( + 'user', + (new RelationDefinition())->setTarget('user')->setType('belongsTo'), + ); + $e->getRelations()->get('user')->getOptions()->set('nullable', true); + + $table = $this->renderPlainWithUser($e, $u, $userFirst); + + $this->assertTrue($table->column('user_p_id')->isNullable()); + } + + /** + * @dataProvider belongsToNullableOrders + */ + public function testNullableBelongsToKeepsUserDefinedColumn(bool $userFirst): void + { + $e = Plain::define(); + $u = User::define(); + + $e->getFields()->set('user_p_id', (new Field())->setType('int')->setColumn('user_p_id')); + $u->getRelations()->get('plain')->setType('hasMany'); + $e->getRelations()->set( + 'user', + (new RelationDefinition())->setTarget('user')->setType('belongsTo'), + ); + $e->getRelations()->get('user')->getOptions()->set('nullable', true); + + $table = $this->renderPlainWithUser($e, $u, $userFirst); + + $this->assertFalse($table->column('user_p_id')->isNullable()); + } + + private function renderPlainWithUser(Entity $plain, Entity $user, bool $userFirst): AbstractTable + { + $r = new Registry($this->dbal); + $entities = $userFirst ? [[$user, 'user'], [$plain, 'plain']] : [[$plain, 'plain'], [$user, 'user']]; + foreach ($entities as [$entity, $table]) { + $r->register($entity)->linkTable($entity, 'default', $table); + } + + (new Compiler())->compile($r, [ + new GenerateRelations(['hasMany' => new HasMany(), 'belongsTo' => new BelongsTo()]), + $t = new RenderTables(), + new RenderRelations(), + ]); + + $t->getReflector()->run(); + + return $this->getDriver()->getSchema('plain'); + } } diff --git a/tests/Schema/Relation/Traits/FieldTraitTest.php b/tests/Schema/Relation/Traits/FieldTraitTest.php index ba15a62..db95e67 100644 --- a/tests/Schema/Relation/Traits/FieldTraitTest.php +++ b/tests/Schema/Relation/Traits/FieldTraitTest.php @@ -94,6 +94,40 @@ public function testEnsureFieldIfFieldExistsItShouldNotBeCreated(): void $this->assertCount(1, $target->getFields()); } + /** + * @dataProvider nullableOrders + */ + public function testEnsureFieldMergesNullableOfRelationGeneratedField(bool $first, bool $second): void + { + $target = new Entity(); + $outer = (new Field())->setColumn('id')->setType('primary'); + + $this->ensureField($target, 'user_id', $outer, $first); + $this->ensureField($target, 'user_id', $outer, $second); + + $field = $target->getFields()->get('user_id'); + $this->assertTrue($field->isRelationGenerated()); + $this->assertTrue($field->getOptions()->get(Column::OPT_NULLABLE)); + } + + public function nullableOrders(): iterable + { + yield 'not nullable first' => [false, true]; + yield 'nullable first' => [true, false]; + } + + public function testEnsureFieldDoesNotChangeUserDefinedField(): void + { + $target = new Entity(); + $outer = (new Field())->setColumn('id')->setType('primary'); + $target->getFields()->set('user_id', $userField = (new Field())->setColumn('user_id')->setType('int')); + + $this->ensureField($target, 'user_id', $outer, true); + + $this->assertFalse($userField->isRelationGenerated()); + $this->assertFalse($userField->getOptions()->has(Column::OPT_NULLABLE)); + } + /** * @dataProvider outerFieldTypes */