From 036896e288c09cfe6d80caec08b5853d15a52e7a Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Wed, 1 Nov 2023 15:10:32 +0200 Subject: [PATCH 1/9] Add the ability to add foreign key via attribute --- src/Annotation/Entity.php | 7 ++ src/Annotation/ForeignKey.php | 44 +++++++++ src/Entities.php | 44 ++++----- src/ForeignKeysConfigurator.php | 161 ++++++++++++++++++++++++++++++++ src/Utils/EntityUtils.php | 22 +++++ 5 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 src/Annotation/ForeignKey.php create mode 100644 src/ForeignKeysConfigurator.php diff --git a/src/Annotation/Entity.php b/src/Annotation/Entity.php index c25d17b8..140bd698 100644 --- a/src/Annotation/Entity.php +++ b/src/Annotation/Entity.php @@ -29,6 +29,7 @@ final 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( private ?string $role = null, @@ -43,6 +44,7 @@ public function __construct( private array $columns = [], /** @deprecated Use {@see $scope} instead */ private ?string $constrain = null, + private array $foreignKeys = [], ) { } @@ -91,6 +93,11 @@ public function getColumns(): array return $this->columns; } + public function getForeignKeys(): array + { + return $this->foreignKeys; + } + public function getTypecast(): array|string|null { if (is_array($this->typecast) && count($this->typecast) === 1) { diff --git a/src/Annotation/ForeignKey.php b/src/Annotation/ForeignKey.php new file mode 100644 index 00000000..2b7d9110 --- /dev/null +++ b/src/Annotation/ForeignKey.php @@ -0,0 +1,44 @@ +outerKey; + } + + public function getInnerKey(): ?array + { + return $this->innerKey === null ? null : (array) $this->innerKey; + } +} diff --git a/src/Entities.php b/src/Entities.php index 3bf8aadd..94e78fbf 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -29,15 +29,19 @@ final class Entities implements GeneratorInterface private ReaderInterface $reader; private Configurator $generator; private EntityUtils $utils; + private ForeignKeysConfigurator $foreignKeysConfigurator; public function __construct( private ClassesInterface $locator, DoctrineReader|ReaderInterface $reader = null, - int $tableNamingStrategy = self::TABLE_NAMING_PLURAL + int $tableNamingStrategy = self::TABLE_NAMING_PLURAL, + $foreignKeysConfigurator = null ) { $this->reader = ReaderFactory::create($reader); $this->utils = new EntityUtils($this->reader); $this->generator = new Configurator($this->reader, $tableNamingStrategy); + $this->foreignKeysConfigurator = $foreignKeysConfigurator + ?? new ForeignKeysConfigurator($this->utils, $this->reader); } public function run(Registry $registry): Registry @@ -70,11 +74,19 @@ public function run(Registry $registry): Registry // additional columns (mapped to local fields automatically) $this->generator->initColumns($e, $ann->getColumns(), $class); + // foreign keys + $this->foreignKeysConfigurator->addFromEntity($e, $ann); + $this->foreignKeysConfigurator->addFromClass($e, $class); + if ($this->utils->hasParent($e->getClass())) { foreach ($this->utils->findParents($e->getClass()) as $parent) { // additional columns from parent class $ann = $this->reader->firstClassMetadata($parent, Entity::class); $this->generator->initColumns($e, $ann->getColumns(), $parent); + + // additional foreign keys from parent class + $this->foreignKeysConfigurator->addFromEntity($e, $ann); + $this->foreignKeysConfigurator->addFromClass($e, $parent); } $children[] = $e; @@ -86,6 +98,9 @@ public function run(Registry $registry): Registry $registry->linkTable($e, $e->getDatabase(), $e->getTableName()); } + // register foreign keys + $this->foreignKeysConfigurator->configure($registry); + foreach ($children as $e) { $registry->registerChildWithoutMerge($registry->getEntity($this->utils->findParent($e->getClass())), $e); } @@ -106,14 +121,14 @@ private function normalizeNames(Registry $registry): Registry // relations foreach ($e->getRelations() as $name => $r) { try { - $r->setTarget($this->resolveTarget($registry, $r->getTarget())); + $r->setTarget($this->utils->resolveTarget($registry, $r->getTarget())); if ($r->getOptions()->has('though')) { $though = $r->getOptions()->get('though'); if ($though !== null) { $r->getOptions()->set( 'though', - $this->resolveTarget($registry, $though) + $this->utils->resolveTarget($registry, $though) ); } } @@ -123,7 +138,7 @@ private function normalizeNames(Registry $registry): Registry if ($through !== null) { $r->getOptions()->set( 'through', - $this->resolveTarget($registry, $through) + $this->utils->resolveTarget($registry, $through) ); } } @@ -155,25 +170,4 @@ private function normalizeNames(Registry $registry): Registry return $registry; } - - private function resolveTarget(Registry $registry, string $name): ?string - { - if (interface_exists($name, true)) { - // do not resolve interfaces - return $name; - } - - if (!$registry->hasEntity($name)) { - // point all relations to the parent - foreach ($registry as $entity) { - foreach ($registry->getChildren($entity) as $child) { - if ($child->getClass() === $name || $child->getRole() === $name) { - return $entity->getRole(); - } - } - } - } - - return $registry->getEntity($name)->getRole(); - } } diff --git a/src/ForeignKeysConfigurator.php b/src/ForeignKeysConfigurator.php new file mode 100644 index 00000000..276c09cb --- /dev/null +++ b/src/ForeignKeysConfigurator.php @@ -0,0 +1,161 @@ + + */ + private array $foreignKeys = []; + + public function __construct( + private EntityUtils $utils, + private ReaderInterface $reader, + ) { + } + + public function addFromEntity(EntitySchema $entity, Entity $annotation): void + { + foreach ($annotation->getForeignKeys() as $foreignKey) { + $this->add($entity, $foreignKey); + } + } + + public function addFromClass(EntitySchema $entity, \ReflectionClass $class): void + { + try { + /** @var ForeignKey $ann */ + $ann = $this->reader->firstClassMetadata($class, ForeignKey::class); + } catch (\Exception $e) { + throw new AnnotationException($e->getMessage(), $e->getCode(), $e); + } + + if ($ann !== null) { + $this->add($entity, $ann); + } + + foreach ($class->getProperties() as $property) { + try { + /** @var ForeignKey $ann */ + $ann = $this->reader->firstPropertyMetadata($property, ForeignKey::class); + } catch (\Exception $e) { + throw new AnnotationException($e->getMessage(), $e->getCode(), $e); + } + + if ($ann !== null) { + $this->add($entity, $ann, $property); + } + } + } + + public function configure(Registry $registry): void + { + foreach ($this->foreignKeys as $foreignKey) { + $attribute = $foreignKey['foreignKey']; + $entity = $foreignKey['entity']; + $property = $foreignKey['property']; + + $target = $this->utils->resolveTarget($registry, $attribute); + \assert(!empty($target), 'Unable to resolve foreign key target entity.'); + $targetEntity = $registry->getEntity($target); + + $registry->getTableSchema($entity) + ->foreignKey( + $this->getInnerColumns($entity, $attribute->getInnerKey(), $property), + $foreignKey->indexCreate + ) + ->references( + $registry->getTable($targetEntity), + $this->getOuterColumns($attribute->getOuterKey(), $targetEntity) + ) + ->onUpdate($attribute->action) + ->onDelete($attribute->action); + } + } + + /** + * @param non-empty-string $key + * + * @return non-empty-string + */ + private function getColumn(EntitySchema $entity, string $key): string + { + return match (true) { + $entity->getFields()->has($key) => $entity->getFields()->get($key)->getColumn(), + $entity->getFields()->hasColumn($key) => $key, + default => throw new AnnotationException('Unable to resolve column name.'), + }; + } + + /** + * @param array|null $innerKeys + * + * @return array + * + * @throws AnnotationException + */ + private function getInnerColumns( + EntitySchema $entity, + ?array $innerKeys = null, + ?\ReflectionProperty $property = null + ): array { + if ($innerKeys === null && $property === null) { + throw new AnnotationException('Unable to resolve foreign key column.'); + } + + if ($innerKeys === null) { + $innerKeys = [$property->getName()]; + } + + $columns = []; + foreach ($innerKeys as $innerKey) { + $columns[] = $this->getColumn($entity, $innerKey); + } + + return $columns; + } + + /** + * @param array $outerKeys + * + * @return array + * + * @throws AnnotationException + */ + private function getOuterColumns(array $outerKeys, EntitySchema $target): array + { + $columns = []; + foreach ($outerKeys as $outerKey) { + $columns[] = $this->getColumn($target, $outerKey); + } + + return $columns; + } + + private function add(EntitySchema $entity, ForeignKey $foreignKey, ?\ReflectionProperty $property = null): void + { + $this->foreignKeys[] = [ + 'entity' => $entity, + 'foreignKey' => $foreignKey, + 'property' => $property, + ]; + } +} diff --git a/src/Utils/EntityUtils.php b/src/Utils/EntityUtils.php index 0bf82b96..90009206 100644 --- a/src/Utils/EntityUtils.php +++ b/src/Utils/EntityUtils.php @@ -6,6 +6,7 @@ use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Entities; +use Cycle\Schema\Registry; use Doctrine\Inflector\Rules\English\InflectorFactory; use Spiral\Attributes\ReaderInterface; @@ -74,4 +75,25 @@ public function tableName(string $role, int $namingStrategy = Entities::TABLE_NA default => $this->inflector->tableize($role), }; } + + public function resolveTarget(Registry $registry, string $name): ?string + { + if (\interface_exists($name, true)) { + // do not resolve interfaces + return $name; + } + + if (!$registry->hasEntity($name)) { + // point all relations to the parent + foreach ($registry as $entity) { + foreach ($registry->getChildren($entity) as $child) { + if ($child->getClass() === $name || $child->getRole() === $name) { + return $entity->getRole(); + } + } + } + } + + return $registry->getEntity($name)->getRole(); + } } From eddcc648104bcee92fd36876c9d4dd8fc868b61a Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Wed, 1 Nov 2023 13:20:38 +0000 Subject: [PATCH 2/9] Apply fixes from StyleCI --- src/ForeignKeysConfigurator.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ForeignKeysConfigurator.php b/src/ForeignKeysConfigurator.php index 276c09cb..5784624c 100644 --- a/src/ForeignKeysConfigurator.php +++ b/src/ForeignKeysConfigurator.php @@ -108,9 +108,9 @@ private function getColumn(EntitySchema $entity, string $key): string /** * @param array|null $innerKeys * - * @return array - * * @throws AnnotationException + * + * @return array */ private function getInnerColumns( EntitySchema $entity, @@ -136,9 +136,9 @@ private function getInnerColumns( /** * @param array $outerKeys * - * @return array - * * @throws AnnotationException + * + * @return array */ private function getOuterColumns(array $outerKeys, EntitySchema $target): array { From 2d683fce6457307c6172f566f067ed44e135568b Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Thu, 2 Nov 2023 13:44:46 +0200 Subject: [PATCH 3/9] Remove ForeignKeysConfigurator --- composer.json | 2 +- src/Annotation/ForeignKey.php | 14 +----- src/Configurator.php | 89 +++++++++++++++++++++++++++++++---- src/Entities.php | 53 ++++++++++++++------- 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/composer.json b/composer.json index 08aefc84..3d6ddc7c 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require": { "php": ">=8.0", "cycle/orm": "^2.2.0", - "cycle/schema-builder": "^2.4", + "cycle/schema-builder": "^2.6", "doctrine/annotations": "^1.14.3 || ^2.0.1", "spiral/attributes": "^2.8|^3.0", "spiral/tokenizer": "^2.8|^3.0", diff --git a/src/Annotation/ForeignKey.php b/src/Annotation/ForeignKey.php index 2b7d9110..bca5dc01 100644 --- a/src/Annotation/ForeignKey.php +++ b/src/Annotation/ForeignKey.php @@ -21,8 +21,8 @@ class ForeignKey { public function __construct( public string $target, - protected array|string $outerKey, - protected array|string|null $innerKey = null, + public array|string $outerKey, + public array|string|null $innerKey = null, /** * @Enum({"NO ACTION", "CASCADE", "SET NULL"}) */ @@ -31,14 +31,4 @@ public function __construct( public bool $indexCreate = true, ) { } - - public function getOuterKey(): array - { - return (array) $this->outerKey; - } - - public function getInnerKey(): ?array - { - return $this->innerKey === null ? null : (array) $this->innerKey; - } } diff --git a/src/Configurator.php b/src/Configurator.php index d4000066..864489b8 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; @@ -110,11 +112,8 @@ 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(), $e->getCode(), $e); - } + + $metadata = $this->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class); foreach ($metadata as $meta) { assert($meta instanceof RelationAnnotation\RelationInterface); @@ -168,11 +167,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(), $e->getCode(), $e); - } + $metadata = $this->getClassMetadata($class, SchemaModifierInterface::class); foreach ($metadata as $meta) { assert($meta instanceof SchemaModifierInterface); @@ -254,6 +249,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->setTable($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. */ @@ -300,4 +333,40 @@ private function resolveTypecast(mixed $typecast, \ReflectionClass $class): mixe return $typecast; } + + /** + * @template T + * + * @param class-string|null + * + * @return iterable + * + * @throws AnnotationException + */ + private function getClassMetadata(\ReflectionClass $class, string $name): iterable + { + try { + return $this->reader->getClassMetadata($class, $name); + } catch (\Exception $e) { + throw new AnnotationException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * @template T + * + * @param class-string|null $name + * + * @return iterable + * + * @throws AnnotationException + */ + private function getPropertyMetadata(\ReflectionProperty $property, string $name): iterable + { + try { + return $this->reader->getPropertyMetadata($property, $name); + } catch (\Exception $e) { + throw new AnnotationException($e->getMessage(), $e->getCode(), $e); + } + } } diff --git a/src/Entities.php b/src/Entities.php index 94e78fbf..813567fa 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -29,19 +29,15 @@ final class Entities implements GeneratorInterface private ReaderInterface $reader; private Configurator $generator; private EntityUtils $utils; - private ForeignKeysConfigurator $foreignKeysConfigurator; public function __construct( private ClassesInterface $locator, DoctrineReader|ReaderInterface $reader = null, int $tableNamingStrategy = self::TABLE_NAMING_PLURAL, - $foreignKeysConfigurator = null ) { $this->reader = ReaderFactory::create($reader); $this->utils = new EntityUtils($this->reader); $this->generator = new Configurator($this->reader, $tableNamingStrategy); - $this->foreignKeysConfigurator = $foreignKeysConfigurator - ?? new ForeignKeysConfigurator($this->utils, $this->reader); } public function run(Registry $registry): Registry @@ -71,22 +67,17 @@ public function run(Registry $registry): Registry // schema modifiers $this->generator->initModifiers($e, $class); + // foreign keys + $this->generator->initForeignKeys($ann, $e, $class); + // additional columns (mapped to local fields automatically) $this->generator->initColumns($e, $ann->getColumns(), $class); - // foreign keys - $this->foreignKeysConfigurator->addFromEntity($e, $ann); - $this->foreignKeysConfigurator->addFromClass($e, $class); - if ($this->utils->hasParent($e->getClass())) { foreach ($this->utils->findParents($e->getClass()) as $parent) { // additional columns from parent class $ann = $this->reader->firstClassMetadata($parent, Entity::class); $this->generator->initColumns($e, $ann->getColumns(), $parent); - - // additional foreign keys from parent class - $this->foreignKeysConfigurator->addFromEntity($e, $ann); - $this->foreignKeysConfigurator->addFromClass($e, $parent); } $children[] = $e; @@ -98,9 +89,6 @@ public function run(Registry $registry): Registry $registry->linkTable($e, $e->getDatabase(), $e->getTableName()); } - // register foreign keys - $this->foreignKeysConfigurator->configure($registry); - foreach ($children as $e) { $registry->registerChildWithoutMerge($registry->getEntity($this->utils->findParent($e->getClass())), $e); } @@ -110,7 +98,6 @@ public function run(Registry $registry): Registry private function normalizeNames(Registry $registry): Registry { - // resolve all the relation target names into roles foreach ($this->locator->getClasses() as $class) { if (! $registry->hasEntity($class->getName())) { continue; @@ -118,7 +105,7 @@ private function normalizeNames(Registry $registry): Registry $e = $registry->getEntity($class->getName()); - // relations + // resolve all the relation target names into roles foreach ($e->getRelations() as $name => $r) { try { $r->setTarget($this->utils->resolveTarget($registry, $r->getTarget())); @@ -166,8 +153,40 @@ private function normalizeNames(Registry $registry): Registry ); } } + + // resolve foreign key table and column names + foreach ($e->getForeignKeys() as $foreignKey) { + $target = $this->utils->resolveTarget($registry, $foreignKey->getTable()); + \assert(!empty($target), 'Unable to resolve foreign key target entity.'); + $targetEntity = $registry->getEntity($target); + + $foreignKey->setTable($targetEntity->getTableName()); + $foreignKey->setInnerColumns($this->getColumnNames($e, $foreignKey->getInnerColumns())); + $foreignKey->setOuterColumns($this->getColumnNames($targetEntity, $foreignKey->getOuterColumns())); + } } return $registry; } + + /** + * @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; + } } From b879b845b092f38f2bf5e1d2c7d7d38c8830b5e4 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 2 Nov 2023 11:45:03 +0000 Subject: [PATCH 4/9] Apply fixes from StyleCI --- src/Configurator.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Configurator.php b/src/Configurator.php index 864489b8..8c42f302 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -112,7 +112,6 @@ public function initFields(EntitySchema $entity, \ReflectionClass $class, string public function initRelations(EntitySchema $entity, \ReflectionClass $class): void { foreach ($class->getProperties() as $property) { - $metadata = $this->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class); foreach ($metadata as $meta) { @@ -339,9 +338,9 @@ private function resolveTypecast(mixed $typecast, \ReflectionClass $class): mixe * * @param class-string|null * - * @return iterable - * * @throws AnnotationException + * + * @return iterable */ private function getClassMetadata(\ReflectionClass $class, string $name): iterable { @@ -357,9 +356,9 @@ private function getClassMetadata(\ReflectionClass $class, string $name): iterab * * @param class-string|null $name * - * @return iterable - * * @throws AnnotationException + * + * @return iterable */ private function getPropertyMetadata(\ReflectionProperty $property, string $name): iterable { From 10ee2ade4ac39a0403cc71e16aaf506cc2631949 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Thu, 2 Nov 2023 13:50:34 +0200 Subject: [PATCH 5/9] Revert resolveTarget method --- src/Entities.php | 29 +++++- src/ForeignKeysConfigurator.php | 161 -------------------------------- src/Utils/EntityUtils.php | 22 ----- 3 files changed, 25 insertions(+), 187 deletions(-) delete mode 100644 src/ForeignKeysConfigurator.php diff --git a/src/Entities.php b/src/Entities.php index 813567fa..5fb8a5f5 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -108,14 +108,14 @@ private function normalizeNames(Registry $registry): Registry // resolve all the relation target names into roles foreach ($e->getRelations() as $name => $r) { try { - $r->setTarget($this->utils->resolveTarget($registry, $r->getTarget())); + $r->setTarget($this->resolveTarget($registry, $r->getTarget())); if ($r->getOptions()->has('though')) { $though = $r->getOptions()->get('though'); if ($though !== null) { $r->getOptions()->set( 'though', - $this->utils->resolveTarget($registry, $though) + $this->resolveTarget($registry, $though) ); } } @@ -125,7 +125,7 @@ private function normalizeNames(Registry $registry): Registry if ($through !== null) { $r->getOptions()->set( 'through', - $this->utils->resolveTarget($registry, $through) + $this->resolveTarget($registry, $through) ); } } @@ -156,7 +156,7 @@ private function normalizeNames(Registry $registry): Registry // resolve foreign key table and column names foreach ($e->getForeignKeys() as $foreignKey) { - $target = $this->utils->resolveTarget($registry, $foreignKey->getTable()); + $target = $this->resolveTarget($registry, $foreignKey->getTable()); \assert(!empty($target), 'Unable to resolve foreign key target entity.'); $targetEntity = $registry->getEntity($target); @@ -169,6 +169,27 @@ private function normalizeNames(Registry $registry): Registry return $registry; } + private function resolveTarget(Registry $registry, string $name): ?string + { + if (\interface_exists($name, true)) { + // do not resolve interfaces + return $name; + } + + if (!$registry->hasEntity($name)) { + // point all relations to the parent + foreach ($registry as $entity) { + foreach ($registry->getChildren($entity) as $child) { + if ($child->getClass() === $name || $child->getRole() === $name) { + return $entity->getRole(); + } + } + } + } + + return $registry->getEntity($name)->getRole(); + } + /** * @param array $columns * diff --git a/src/ForeignKeysConfigurator.php b/src/ForeignKeysConfigurator.php deleted file mode 100644 index 5784624c..00000000 --- a/src/ForeignKeysConfigurator.php +++ /dev/null @@ -1,161 +0,0 @@ - - */ - private array $foreignKeys = []; - - public function __construct( - private EntityUtils $utils, - private ReaderInterface $reader, - ) { - } - - public function addFromEntity(EntitySchema $entity, Entity $annotation): void - { - foreach ($annotation->getForeignKeys() as $foreignKey) { - $this->add($entity, $foreignKey); - } - } - - public function addFromClass(EntitySchema $entity, \ReflectionClass $class): void - { - try { - /** @var ForeignKey $ann */ - $ann = $this->reader->firstClassMetadata($class, ForeignKey::class); - } catch (\Exception $e) { - throw new AnnotationException($e->getMessage(), $e->getCode(), $e); - } - - if ($ann !== null) { - $this->add($entity, $ann); - } - - foreach ($class->getProperties() as $property) { - try { - /** @var ForeignKey $ann */ - $ann = $this->reader->firstPropertyMetadata($property, ForeignKey::class); - } catch (\Exception $e) { - throw new AnnotationException($e->getMessage(), $e->getCode(), $e); - } - - if ($ann !== null) { - $this->add($entity, $ann, $property); - } - } - } - - public function configure(Registry $registry): void - { - foreach ($this->foreignKeys as $foreignKey) { - $attribute = $foreignKey['foreignKey']; - $entity = $foreignKey['entity']; - $property = $foreignKey['property']; - - $target = $this->utils->resolveTarget($registry, $attribute); - \assert(!empty($target), 'Unable to resolve foreign key target entity.'); - $targetEntity = $registry->getEntity($target); - - $registry->getTableSchema($entity) - ->foreignKey( - $this->getInnerColumns($entity, $attribute->getInnerKey(), $property), - $foreignKey->indexCreate - ) - ->references( - $registry->getTable($targetEntity), - $this->getOuterColumns($attribute->getOuterKey(), $targetEntity) - ) - ->onUpdate($attribute->action) - ->onDelete($attribute->action); - } - } - - /** - * @param non-empty-string $key - * - * @return non-empty-string - */ - private function getColumn(EntitySchema $entity, string $key): string - { - return match (true) { - $entity->getFields()->has($key) => $entity->getFields()->get($key)->getColumn(), - $entity->getFields()->hasColumn($key) => $key, - default => throw new AnnotationException('Unable to resolve column name.'), - }; - } - - /** - * @param array|null $innerKeys - * - * @throws AnnotationException - * - * @return array - */ - private function getInnerColumns( - EntitySchema $entity, - ?array $innerKeys = null, - ?\ReflectionProperty $property = null - ): array { - if ($innerKeys === null && $property === null) { - throw new AnnotationException('Unable to resolve foreign key column.'); - } - - if ($innerKeys === null) { - $innerKeys = [$property->getName()]; - } - - $columns = []; - foreach ($innerKeys as $innerKey) { - $columns[] = $this->getColumn($entity, $innerKey); - } - - return $columns; - } - - /** - * @param array $outerKeys - * - * @throws AnnotationException - * - * @return array - */ - private function getOuterColumns(array $outerKeys, EntitySchema $target): array - { - $columns = []; - foreach ($outerKeys as $outerKey) { - $columns[] = $this->getColumn($target, $outerKey); - } - - return $columns; - } - - private function add(EntitySchema $entity, ForeignKey $foreignKey, ?\ReflectionProperty $property = null): void - { - $this->foreignKeys[] = [ - 'entity' => $entity, - 'foreignKey' => $foreignKey, - 'property' => $property, - ]; - } -} diff --git a/src/Utils/EntityUtils.php b/src/Utils/EntityUtils.php index 90009206..0bf82b96 100644 --- a/src/Utils/EntityUtils.php +++ b/src/Utils/EntityUtils.php @@ -6,7 +6,6 @@ use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Entities; -use Cycle\Schema\Registry; use Doctrine\Inflector\Rules\English\InflectorFactory; use Spiral\Attributes\ReaderInterface; @@ -75,25 +74,4 @@ public function tableName(string $role, int $namingStrategy = Entities::TABLE_NA default => $this->inflector->tableize($role), }; } - - public function resolveTarget(Registry $registry, string $name): ?string - { - if (\interface_exists($name, true)) { - // do not resolve interfaces - return $name; - } - - if (!$registry->hasEntity($name)) { - // point all relations to the parent - foreach ($registry as $entity) { - foreach ($registry->getChildren($entity) as $child) { - if ($child->getClass() === $name || $child->getRole() === $name) { - return $entity->getRole(); - } - } - } - } - - return $registry->getEntity($name)->getRole(); - } } From c68b87c4454a94980bd2235c8f86046be4c05e0c Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Thu, 2 Nov 2023 16:38:28 +0200 Subject: [PATCH 6/9] Add tests --- src/Configurator.php | 2 +- src/Entities.php | 6 +- .../Class/DatabaseField/DatabaseField.php | 30 ++++++ .../Fixtures24/Class/DatabaseField/Target.php | 27 ++++++ .../Class/PrimaryKey/PrimaryKey.php | 30 ++++++ .../Fixtures24/Class/PrimaryKey/Target.php | 21 ++++ .../Class/PropertyName/PropertyName.php | 30 ++++++ .../Fixtures24/Class/PropertyName/Target.php | 27 ++++++ .../Entity/DatabaseField/DatabaseField.php | 31 ++++++ .../Entity/DatabaseField/Target.php | 24 +++++ .../Entity/PrimaryKey/PrimaryKey.php | 31 ++++++ .../Fixtures24/Entity/PrimaryKey/Target.php | 19 ++++ .../Entity/PropertyName/PropertyName.php | 31 ++++++ .../Fixtures24/Entity/PropertyName/Target.php | 24 +++++ .../Property/DatabaseField/DatabaseField.php | 30 ++++++ .../Property/DatabaseField/Target.php | 27 ++++++ .../Property/PrimaryKey/PrimaryKey.php | 30 ++++++ .../Fixtures24/Property/PrimaryKey/Target.php | 21 ++++ .../Property/PropertyName/PropertyName.php | 30 ++++++ .../Property/PropertyName/Target.php | 27 ++++++ .../Functional/Driver/Common/TableTest.php | 95 +++++++++++++++++++ 21 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Class/PrimaryKey/PrimaryKey.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Class/PrimaryKey/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Class/PropertyName/PropertyName.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Class/PropertyName/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Entity/DatabaseField/DatabaseField.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Entity/DatabaseField/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Entity/PrimaryKey/PrimaryKey.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Entity/PrimaryKey/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Entity/PropertyName/PropertyName.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Entity/PropertyName/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Property/DatabaseField/DatabaseField.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Property/DatabaseField/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Property/PrimaryKey/PrimaryKey.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Property/PrimaryKey/Target.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Property/PropertyName/PropertyName.php create mode 100644 tests/Annotated/Fixtures/Fixtures24/Property/PropertyName/Target.php diff --git a/src/Configurator.php b/src/Configurator.php index 8c42f302..e51fe0f7 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -276,7 +276,7 @@ public function initForeignKeys(Entity $ann, EntitySchema $entity, \ReflectionCl } $fk = new ForeignKeySchema(); - $fk->setTable($foreignKey->target); + $fk->setTarget($foreignKey->target); $fk->setInnerColumns((array) $foreignKey->innerKey); $fk->setOuterColumns((array) $foreignKey->outerKey); $fk->createIndex($foreignKey->indexCreate); diff --git a/src/Entities.php b/src/Entities.php index 5fb8a5f5..c38aeb17 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -154,13 +154,13 @@ private function normalizeNames(Registry $registry): Registry } } - // resolve foreign key table and column names + // resolve foreign key target and column names foreach ($e->getForeignKeys() as $foreignKey) { - $target = $this->resolveTarget($registry, $foreignKey->getTable()); + $target = $this->resolveTarget($registry, $foreignKey->getTarget()); \assert(!empty($target), 'Unable to resolve foreign key target entity.'); $targetEntity = $registry->getEntity($target); - $foreignKey->setTable($targetEntity->getTableName()); + $foreignKey->setTarget($target); $foreignKey->setInnerColumns($this->getColumnNames($e, $foreignKey->getInnerColumns())); $foreignKey->setOuterColumns($this->getColumnNames($targetEntity, $foreignKey->getOuterColumns())); } 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..e60c1dff --- /dev/null +++ b/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php @@ -0,0 +1,30 @@ +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($locator, $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 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' + ]; + } } From ce9c9349e5e230a24a8b734e8b8d6f681f8f68de Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 2 Nov 2023 14:38:48 +0000 Subject: [PATCH 7/9] Apply fixes from StyleCI --- .../Annotated/Functional/Driver/Common/TableTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Annotated/Functional/Driver/Common/TableTest.php b/tests/Annotated/Functional/Driver/Common/TableTest.php index 4bd636bc..ff1a02fb 100644 --- a/tests/Annotated/Functional/Driver/Common/TableTest.php +++ b/tests/Annotated/Functional/Driver/Common/TableTest.php @@ -364,16 +364,16 @@ public function foreignKeyDirectoriesDataProvider(): \Traversable yield [ new SelectiveReader([new AnnotationReader(), new AttributeReader()]), - '/Fixtures/Fixtures24/Class/DatabaseField' + '/Fixtures/Fixtures24/Class/DatabaseField', ]; yield [ new SelectiveReader([new AnnotationReader(), new AttributeReader()]), '/Fixtures/Fixtures24/Class/PrimaryKey', - 'id' + 'id', ]; yield [ new SelectiveReader([new AnnotationReader(), new AttributeReader()]), - '/Fixtures/Fixtures24/Class/PropertyName' + '/Fixtures/Fixtures24/Class/PropertyName', ]; yield [new AnnotationReader(), '/Fixtures/Fixtures24/Entity/DatabaseField']; @@ -390,16 +390,16 @@ public function foreignKeyDirectoriesDataProvider(): \Traversable yield [ new SelectiveReader([new AnnotationReader(), new AttributeReader()]), - '/Fixtures/Fixtures24/Property/DatabaseField' + '/Fixtures/Fixtures24/Property/DatabaseField', ]; yield [ new SelectiveReader([new AnnotationReader(), new AttributeReader()]), '/Fixtures/Fixtures24/Property/PrimaryKey', - 'id' + 'id', ]; yield [ new SelectiveReader([new AnnotationReader(), new AttributeReader()]), - '/Fixtures/Fixtures24/Property/PropertyName' + '/Fixtures/Fixtures24/Property/PropertyName', ]; } } From dcab4814e0d80ecaff7a18cd36412e17b2b7b2ab Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 23 Nov 2023 18:44:34 +0400 Subject: [PATCH 8/9] Support nullable inner key in FK --- src/Annotation/ForeignKey.php | 11 ++++++++++- src/Entities.php | 5 ++++- .../Fixtures24/Class/DatabaseField/DatabaseField.php | 2 +- .../Fixtures24/Class/PropertyName/PropertyName.php | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Annotation/ForeignKey.php b/src/Annotation/ForeignKey.php index bca5dc01..14254d58 100644 --- a/src/Annotation/ForeignKey.php +++ b/src/Annotation/ForeignKey.php @@ -19,10 +19,19 @@ #[NamedArgumentConstructor] class ForeignKey { + /** + * @param non-empty-string $target Role or class name of the target entity. + * @param list|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 'NO ACTION'|'CASCADE'|'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 $outerKey, public array|string|null $innerKey = null, + public array|string|null $outerKey = null, /** * @Enum({"NO ACTION", "CASCADE", "SET NULL"}) */ diff --git a/src/Entities.php b/src/Entities.php index c38aeb17..fec0d232 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -162,7 +162,10 @@ private function normalizeNames(Registry $registry): Registry $foreignKey->setTarget($target); $foreignKey->setInnerColumns($this->getColumnNames($e, $foreignKey->getInnerColumns())); - $foreignKey->setOuterColumns($this->getColumnNames($targetEntity, $foreignKey->getOuterColumns())); + + $foreignKey->setOuterColumns(empty($foreignKey->getOuterColumns()) + ? $targetEntity->getPrimaryFields()->getColumnNames() + : $this->getColumnNames($targetEntity, $foreignKey->getOuterColumns())); } } diff --git a/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php b/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php index e60c1dff..a08ffb12 100644 --- a/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php +++ b/tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/DatabaseField.php @@ -12,7 +12,7 @@ * @Entity(role="from", table="from") * @ForeignKey(target="target", outerKey="outer_key", innerKey="inner_key") */ -#[ForeignKey(target: Target::class, outerKey: 'outer_key', innerKey: 'inner_key')] +#[ForeignKey(target: Target::class, innerKey: 'inner_key', outerKey: 'outer_key')] #[Entity(role: 'from', table: 'from')] class DatabaseField { diff --git a/tests/Annotated/Fixtures/Fixtures24/Class/PropertyName/PropertyName.php b/tests/Annotated/Fixtures/Fixtures24/Class/PropertyName/PropertyName.php index 832c621c..dde8e0a9 100644 --- a/tests/Annotated/Fixtures/Fixtures24/Class/PropertyName/PropertyName.php +++ b/tests/Annotated/Fixtures/Fixtures24/Class/PropertyName/PropertyName.php @@ -12,7 +12,7 @@ * @Entity(role="from", table="from") * @ForeignKey(target="target", outerKey="outerKey", innerKey="innerKey") */ -#[ForeignKey(target: Target::class, outerKey: 'outerKey', innerKey: 'innerKey')] +#[ForeignKey(target: Target::class, innerKey: 'innerKey', outerKey: 'outerKey')] #[Entity(role: 'from', table: 'from')] class PropertyName { From b4fa5c0db107cc44afafa0bc2d348c14db5687fd Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 23 Nov 2023 14:45:12 +0000 Subject: [PATCH 9/9] Apply fixes from StyleCI --- src/Annotation/ForeignKey.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Annotation/ForeignKey.php b/src/Annotation/ForeignKey.php index 14254d58..f77bea6d 100644 --- a/src/Annotation/ForeignKey.php +++ b/src/Annotation/ForeignKey.php @@ -25,7 +25,7 @@ class ForeignKey * is used on a property. * @param list|non-empty-string|null $outerKey Outer key in the target entity. * Defaults to the primary key. - * @param 'NO ACTION'|'CASCADE'|'SET NULL' $action + * @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(