diff --git a/composer.json b/composer.json index 2bc9d2d0..1faf230a 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require": { "php": ">=8.1", "cycle/orm": "^2.2.0", - "cycle/schema-builder": "^2.5.1", + "cycle/schema-builder": "^2.6", "spiral/attributes": "^2.8|^3.0", "spiral/tokenizer": "^2.8|^3.0", "doctrine/inflector": "^2.0" diff --git a/src/Annotation/Column.php b/src/Annotation/Column.php index 81af29d6..f3495ace 100644 --- a/src/Annotation/Column.php +++ b/src/Annotation/Column.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target({"PROPERTY", "ANNOTATION", "CLASS"}) */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] diff --git a/src/Annotation/Embeddable.php b/src/Annotation/Embeddable.php index d272a322..f83c394c 100644 --- a/src/Annotation/Embeddable.php +++ b/src/Annotation/Embeddable.php @@ -9,9 +9,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] diff --git a/src/Annotation/Entity.php b/src/Annotation/Entity.php index 8ba9402b..cb52a473 100644 --- a/src/Annotation/Entity.php +++ b/src/Annotation/Entity.php @@ -9,9 +9,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] @@ -29,6 +27,7 @@ class Entity * @param non-empty-string|non-empty-string[]|null $typecast * @param class-string|null $scope Class name of constraint to be applied to every entity query. * @param Column[] $columns Entity columns. + * @param ForeignKey[] $foreignKeys Entity foreign keys. */ public function __construct( protected ?string $role = null, @@ -40,7 +39,8 @@ public function __construct( protected ?string $source = null, protected array|string|null $typecast = null, protected ?string $scope = null, - protected array $columns = [] + protected array $columns = [], + protected array $foreignKeys = [], ) { } @@ -113,6 +113,14 @@ public function getColumns(): array return $this->columns; } + /** + * @return ForeignKey[] + */ + public function getForeignKeys(): array + { + return $this->foreignKeys; + } + /** * @return non-empty-string|non-empty-string[]|null */ diff --git a/src/Annotation/ForeignKey.php b/src/Annotation/ForeignKey.php new file mode 100644 index 00000000..37b593af --- /dev/null +++ b/src/Annotation/ForeignKey.php @@ -0,0 +1,41 @@ +|non-empty-string|null $innerKey You don't need to specify this if the attribute + * is used on a property. + * @param list|non-empty-string|null $outerKey Outer key in the target entity. + * Defaults to the primary key. + * @param 'CASCADE'|'NO ACTION'|'SET null' $action + * @param bool $indexCreate Note: MySQL and MSSQL might create an index for the foreign key automatically. + */ + public function __construct( + public string $target, + public array|string|null $innerKey = null, + public array|string|null $outerKey = null, + /** + * @Enum({"NO ACTION", "CASCADE", "SET NULL"}) + */ + #[ExpectedValues(values: ['NO ACTION', 'CASCADE', 'SET NULL'])] + public string $action = 'CASCADE', + public bool $indexCreate = true, + ) { + } +} diff --git a/src/Annotation/Inheritance/DiscriminatorColumn.php b/src/Annotation/Inheritance/DiscriminatorColumn.php index be4fab6b..54ba0f96 100644 --- a/src/Annotation/Inheritance/DiscriminatorColumn.php +++ b/src/Annotation/Inheritance/DiscriminatorColumn.php @@ -8,9 +8,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] diff --git a/src/Annotation/Inheritance/JoinedTable.php b/src/Annotation/Inheritance/JoinedTable.php index 2bf44212..e8b2ecc5 100644 --- a/src/Annotation/Inheritance/JoinedTable.php +++ b/src/Annotation/Inheritance/JoinedTable.php @@ -10,9 +10,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] diff --git a/src/Annotation/Inheritance/SingleTable.php b/src/Annotation/Inheritance/SingleTable.php index 47f226b2..b67d02aa 100644 --- a/src/Annotation/Inheritance/SingleTable.php +++ b/src/Annotation/Inheritance/SingleTable.php @@ -9,9 +9,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/BelongsTo.php b/src/Annotation/Relation/BelongsTo.php index 72988952..37343217 100644 --- a/src/Annotation/Relation/BelongsTo.php +++ b/src/Annotation/Relation/BelongsTo.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/Embedded.php b/src/Annotation/Relation/Embedded.php index 6975d38f..3222a164 100644 --- a/src/Annotation/Relation/Embedded.php +++ b/src/Annotation/Relation/Embedded.php @@ -9,9 +9,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/HasMany.php b/src/Annotation/Relation/HasMany.php index 4c9d7ac6..273aca47 100644 --- a/src/Annotation/Relation/HasMany.php +++ b/src/Annotation/Relation/HasMany.php @@ -10,9 +10,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/HasOne.php b/src/Annotation/Relation/HasOne.php index 57e36ae8..9c935afd 100644 --- a/src/Annotation/Relation/HasOne.php +++ b/src/Annotation/Relation/HasOne.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/Inverse.php b/src/Annotation/Relation/Inverse.php index 588f332c..37f5418c 100644 --- a/src/Annotation/Relation/Inverse.php +++ b/src/Annotation/Relation/Inverse.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target({"PROPERTY", "ANNOTATION"}) */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/ManyToMany.php b/src/Annotation/Relation/ManyToMany.php index 83f6557e..f63d03a1 100644 --- a/src/Annotation/Relation/ManyToMany.php +++ b/src/Annotation/Relation/ManyToMany.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/Morphed/BelongsToMorphed.php b/src/Annotation/Relation/Morphed/BelongsToMorphed.php index 892600dd..6bffc085 100644 --- a/src/Annotation/Relation/Morphed/BelongsToMorphed.php +++ b/src/Annotation/Relation/Morphed/BelongsToMorphed.php @@ -13,9 +13,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/Morphed/MorphedHasMany.php b/src/Annotation/Relation/Morphed/MorphedHasMany.php index c3f0f183..2c8f429a 100644 --- a/src/Annotation/Relation/Morphed/MorphedHasMany.php +++ b/src/Annotation/Relation/Morphed/MorphedHasMany.php @@ -12,9 +12,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/Morphed/MorphedHasOne.php b/src/Annotation/Relation/Morphed/MorphedHasOne.php index 6a215996..d00b1649 100644 --- a/src/Annotation/Relation/Morphed/MorphedHasOne.php +++ b/src/Annotation/Relation/Morphed/MorphedHasOne.php @@ -12,9 +12,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Relation/RefersTo.php b/src/Annotation/Relation/RefersTo.php index 4c9a14b0..f6dd91a9 100644 --- a/src/Annotation/Relation/RefersTo.php +++ b/src/Annotation/Relation/RefersTo.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("PROPERTY") */ #[\Attribute(\Attribute::TARGET_PROPERTY), NamedArgumentConstructor] diff --git a/src/Annotation/Table.php b/src/Annotation/Table.php index 78513a44..c93078a9 100644 --- a/src/Annotation/Table.php +++ b/src/Annotation/Table.php @@ -11,9 +11,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target({"CLASS", "ANNOTATION"}) */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] diff --git a/src/Annotation/Table/Index.php b/src/Annotation/Table/Index.php index 0d8f513e..3fbcb041 100644 --- a/src/Annotation/Table/Index.php +++ b/src/Annotation/Table/Index.php @@ -9,9 +9,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("ANNOTATION", "CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE), NamedArgumentConstructor] diff --git a/src/Annotation/Table/PrimaryKey.php b/src/Annotation/Table/PrimaryKey.php index 9fb68b88..c12d2cf5 100644 --- a/src/Annotation/Table/PrimaryKey.php +++ b/src/Annotation/Table/PrimaryKey.php @@ -9,9 +9,7 @@ /** * @Annotation - * * @NamedArgumentConstructor - * * @Target("ANNOTATION", "CLASS") */ #[\Attribute(\Attribute::TARGET_CLASS), NamedArgumentConstructor] diff --git a/src/Configurator.php b/src/Configurator.php index 0a5fad34..a679040e 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -7,12 +7,14 @@ use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Embeddable; use Cycle\Annotated\Annotation\Entity; +use Cycle\Annotated\Annotation\ForeignKey; use Cycle\Annotated\Annotation\Relation as RelationAnnotation; use Cycle\Annotated\Exception\AnnotationException; use Cycle\Annotated\Exception\AnnotationRequiredArgumentsException; use Cycle\Annotated\Exception\AnnotationWrongTypeArgumentException; use Cycle\Annotated\Utils\EntityUtils; use Cycle\Schema\Definition\Entity as EntitySchema; +use Cycle\Schema\Definition\ForeignKey as ForeignKeySchema; use Cycle\Schema\Definition\Field; use Cycle\Schema\Definition\Relation; use Cycle\Schema\Generator\SyncTables; @@ -116,11 +118,7 @@ public function initFields(EntitySchema $entity, \ReflectionClass $class, string public function initRelations(EntitySchema $entity, \ReflectionClass $class): void { foreach ($class->getProperties() as $property) { - try { - $metadata = $this->reader->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class); - } catch (\Exception $e) { - throw new AnnotationException($e->getMessage(), (int) $e->getCode(), $e); - } + $metadata = $this->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class); foreach ($metadata as $meta) { \assert($meta instanceof RelationAnnotation\RelationInterface); @@ -180,11 +178,7 @@ public function initRelations(EntitySchema $entity, \ReflectionClass $class): vo public function initModifiers(EntitySchema $entity, \ReflectionClass $class): void { - try { - $metadata = $this->reader->getClassMetadata($class, SchemaModifierInterface::class); - } catch (\Exception $e) { - throw new AnnotationException($e->getMessage(), (int) $e->getCode(), $e); - } + $metadata = $this->getClassMetadata($class, SchemaModifierInterface::class); foreach ($metadata as $meta) { \assert($meta instanceof SchemaModifierInterface); @@ -265,6 +259,44 @@ public function initField(string $name, Column $column, \ReflectionClass $class, return $field; } + public function initForeignKeys(Entity $ann, EntitySchema $entity, \ReflectionClass $class): void + { + $foreignKeys = []; + foreach ($ann->getForeignKeys() as $foreignKey) { + $foreignKeys[] = $foreignKey; + } + + foreach ($this->getClassMetadata($class, ForeignKey::class) as $foreignKey) { + $foreignKeys[] = $foreignKey; + } + + foreach ($class->getProperties() as $property) { + foreach ($this->getPropertyMetadata($property, ForeignKey::class) as $foreignKey) { + if ($foreignKey->innerKey === null) { + $foreignKey->innerKey = [$property->getName()]; + } + $foreignKeys[] = $foreignKey; + } + } + + foreach ($foreignKeys as $foreignKey) { + if ($foreignKey->innerKey === null) { + throw new AnnotationException( + "Inner column definition for the foreign key is required on `{$entity->getClass()}`" + ); + } + + $fk = new ForeignKeySchema(); + $fk->setTarget($foreignKey->target); + $fk->setInnerColumns((array) $foreignKey->innerKey); + $fk->setOuterColumns((array) $foreignKey->outerKey); + $fk->createIndex($foreignKey->indexCreate); + $fk->setAction($foreignKey->action); + + $entity->getForeignKeys()->set($fk); + } + } + /** * Resolve class or role name relative to the current class. * @@ -318,4 +350,40 @@ private function resolveTypecast(mixed $typecast, \ReflectionClass $class): mixe return $typecast; } + + /** + * @template T + * + * @param class-string $name + * + * @throws AnnotationException + * + * @return iterable + */ + private function getClassMetadata(\ReflectionClass $class, string $name): iterable + { + try { + return $this->reader->getClassMetadata($class, $name); + } catch (\Exception $e) { + throw new AnnotationException($e->getMessage(), (int) $e->getCode(), $e); + } + } + + /** + * @template T + * + * @param class-string $name + * + * @throws AnnotationException + * + * @return iterable + */ + private function getPropertyMetadata(\ReflectionProperty $property, string $name): iterable + { + try { + return $this->reader->getPropertyMetadata($property, $name); + } catch (\Exception $e) { + throw new AnnotationException($e->getMessage(), (int) $e->getCode(), $e); + } + } } diff --git a/src/Entities.php b/src/Entities.php index a25ec4d6..bcfca3c0 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -5,6 +5,7 @@ namespace Cycle\Annotated; use Cycle\Annotated\Annotation\Entity; +use Cycle\Annotated\Exception\AnnotationException; use Cycle\Annotated\Locator\EntityLocatorInterface; use Cycle\Annotated\Utils\EntityUtils; use Cycle\Schema\Definition\Entity as EntitySchema; @@ -32,7 +33,7 @@ final class Entities implements GeneratorInterface public function __construct( private readonly EntityLocatorInterface $locator, DoctrineReader|ReaderInterface $reader = null, - int $tableNamingStrategy = self::TABLE_NAMING_PLURAL + int $tableNamingStrategy = self::TABLE_NAMING_PLURAL, ) { $this->reader = ReaderFactory::create($reader); $this->utils = new EntityUtils($this->reader); @@ -55,6 +56,9 @@ public function run(Registry $registry): Registry // schema modifiers $this->generator->initModifiers($e, $entity->class); + // foreign keys + $this->generator->initForeignKeys($entity->attribute, $e, $entity->class); + // additional columns (mapped to local fields automatically) $this->generator->initColumns($e, $entity->attribute->getColumns(), $entity->class); @@ -101,7 +105,7 @@ private function normalizeNames(Registry $registry): Registry $e = $registry->getEntity($entity->class->getName()); - // relations + // resolve all the relation target names into roles foreach ($e->getRelations() as $name => $r) { try { $r->setTarget($this->resolveTarget($registry, $r->getTarget())); @@ -138,6 +142,20 @@ private function normalizeNames(Registry $registry): Registry ); } } + + // resolve foreign key target and column names + foreach ($e->getForeignKeys() as $foreignKey) { + $target = $this->resolveTarget($registry, $foreignKey->getTarget()); + \assert(!empty($target), 'Unable to resolve foreign key target entity.'); + $targetEntity = $registry->getEntity($target); + + $foreignKey->setTarget($target); + $foreignKey->setInnerColumns($this->getColumnNames($e, $foreignKey->getInnerColumns())); + + $foreignKey->setOuterColumns(empty($foreignKey->getOuterColumns()) + ? $targetEntity->getPrimaryFields()->getColumnNames() + : $this->getColumnNames($targetEntity, $foreignKey->getOuterColumns())); + } } return $registry; @@ -173,4 +191,25 @@ private function resolveTarget(Registry $registry, string $name): string return $target($registry->getEntity($name)); } + + /** + * @param array $columns + * + * @throws AnnotationException + * + * @return array + */ + private function getColumnNames(EntitySchema $entity, array $columns): array + { + $names = []; + foreach ($columns as $name) { + $names[] = match (true) { + $entity->getFields()->has($name) => $entity->getFields()->get($name)->getColumn(), + $entity->getFields()->hasColumn($name) => $name, + default => throw new AnnotationException('Unable to resolve column name.'), + }; + } + + return $names; + } } diff --git a/src/Exception/AnnotationRequiredArgumentsException.php b/src/Exception/AnnotationRequiredArgumentsException.php index c7ec5d8c..60a9474a 100644 --- a/src/Exception/AnnotationRequiredArgumentsException.php +++ b/src/Exception/AnnotationRequiredArgumentsException.php @@ -15,7 +15,7 @@ public static function createFor(\ReflectionProperty $property, string $annotati $requiredArguments = []; foreach ($column->getConstructor()?->getParameters() ?? [] as $parameter) { - if (! $parameter->isOptional()) { + if (!$parameter->isOptional()) { $requiredArguments[] = $parameter->getName(); } } diff --git a/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php b/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php new file mode 100644 index 00000000..a08ffb12 --- /dev/null +++ b/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php @@ -0,0 +1,30 @@ +expectException(\Cycle\Annotated\Exception\AnnotationException::class); + $this->expectException(AnnotationException::class); $this->expectExceptionMessage('Invalid index definition for `compositePost`. Column list can\'t be empty.'); $r = new Registry($this->dbal); @@ -297,4 +300,93 @@ public function testReadonlySchema(ReaderInterface $reader): void $this->assertTrue($schema->column('read_only_column')->isReadonlySchema()); } + + #[DataProvider('foreignKeyDirectoriesDataProvider')] + public function testForeignKeysAnnotationReader( + ReaderInterface $reader, + string $directory, + string $outerKey = 'outer_key' + ): void { + $tokenizer = new Tokenizer( + new TokenizerConfig([ + 'directories' => [dirname(__DIR__, 3) . $directory], + 'exclude' => [], + ]) + ); + + $locator = $tokenizer->classLocator(); + $registry = new Registry($this->dbal); + + (new Compiler())->compile($registry, [ + new Entities(new TokenizerEntityLocator($locator, $reader), $reader), + new MergeColumns($reader), + $t = new RenderTables(), + new MergeIndexes($reader), + new ForeignKeys(), + ]); + + $t->getReflector()->run(); + + $foreignKeys = $registry->getTableSchema($registry->getEntity('from'))->getForeignKeys(); + $expectedFk = array_shift($foreignKeys); + + $this->assertStringContainsString('from', $expectedFk->getTable()); + $this->assertStringContainsString('to', $expectedFk->getForeignTable()); + $this->assertSame(['inner_key'], $expectedFk->getColumns()); + $this->assertSame([$outerKey], $expectedFk->getForeignKeys()); + $this->assertSame('CASCADE', $expectedFk->getDeleteRule()); + $this->assertSame('CASCADE', $expectedFk->getUpdateRule()); + $this->assertTrue($expectedFk->hasIndex()); + } + + public static function foreignKeyDirectoriesDataProvider(): \Traversable + { + yield [new AttributeReader(), '/Fixtures/Fixtures24/Class/DatabaseField']; + yield [new AttributeReader(), '/Fixtures/Fixtures24/Class/PrimaryKey', 'id']; + yield [new AttributeReader(), '/Fixtures/Fixtures24/Class/PropertyName']; + + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Class/DatabaseField']; + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Class/PrimaryKey', 'id']; + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Class/PropertyName']; + + yield [ + new SelectiveReader([new AnnotationReader(), new AttributeReader()]), + '/Fixtures/Fixtures24/Class/DatabaseField', + ]; + yield [ + new SelectiveReader([new AnnotationReader(), new AttributeReader()]), + '/Fixtures/Fixtures24/Class/PrimaryKey', + 'id', + ]; + yield [ + new SelectiveReader([new AnnotationReader(), new AttributeReader()]), + '/Fixtures/Fixtures24/Class/PropertyName', + ]; + + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Entity/DatabaseField']; + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Entity/PrimaryKey', 'id']; + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Entity/PropertyName']; + + yield [new AttributeReader(), '/Fixtures/Fixtures24/Property/DatabaseField']; + yield [new AttributeReader(), '/Fixtures/Fixtures24/Property/PrimaryKey', 'id']; + yield [new AttributeReader(), '/Fixtures/Fixtures24/Property/PropertyName']; + + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Property/DatabaseField']; + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Property/PrimaryKey', 'id']; + yield [new AnnotationReader(), '/Fixtures/Fixtures24/Property/PropertyName']; + + yield [ + new SelectiveReader([new AnnotationReader(), new AttributeReader()]), + '/Fixtures/Fixtures24/Property/DatabaseField', + ]; + yield [ + new SelectiveReader([new AnnotationReader(), new AttributeReader()]), + '/Fixtures/Fixtures24/Property/PrimaryKey', + 'id', + ]; + yield [ + new SelectiveReader([new AnnotationReader(), new AttributeReader()]), + '/Fixtures/Fixtures24/Property/PropertyName', + ]; + } }