Skip to content

Commit

Permalink
Merge pull request #91: expose a Foreign Key definition via attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
roxblnfk authored Nov 23, 2023
2 parents 42ab4a2 + b4fa5c0 commit bb376d2
Show file tree
Hide file tree
Showing 24 changed files with 755 additions and 15 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/Annotation/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +44,7 @@ public function __construct(
private array $columns = [],
/** @deprecated Use {@see $scope} instead */
private ?string $constrain = null,
private array $foreignKeys = [],
) {
}

Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions src/Annotation/ForeignKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Cycle\Annotated\Annotation;

use Doctrine\Common\Annotations\Annotation\Target;
use JetBrains\PhpStorm\ExpectedValues;
use Spiral\Attributes\NamedArgumentConstructor;

/**
* @Annotation
*
* @NamedArgumentConstructor
*
* @Target({"PROPERTY", "ANNOTATION", "CLASS"})
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
#[NamedArgumentConstructor]
class ForeignKey
{
/**
* @param non-empty-string $target Role or class name of the target entity.
* @param list<non-empty-string>|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>|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,
) {
}
}
88 changes: 78 additions & 10 deletions src/Configurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,11 +112,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(), $e->getCode(), $e);
}
$metadata = $this->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class);

foreach ($metadata as $meta) {
assert($meta instanceof RelationAnnotation\RelationInterface);
Expand Down Expand Up @@ -168,11 +166,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);
Expand Down Expand Up @@ -254,6 +248,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.
*/
Expand Down Expand Up @@ -300,4 +332,40 @@ private function resolveTypecast(mixed $typecast, \ReflectionClass $class): mixe

return $typecast;
}

/**
* @template T
*
* @param class-string<T>|null
*
* @throws AnnotationException
*
* @return iterable<T>
*/
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<T>|null $name
*
* @throws AnnotationException
*
* @return iterable<T>
*/
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);
}
}
}
45 changes: 41 additions & 4 deletions src/Entities.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ final class Entities implements GeneratorInterface
public function __construct(
private ClassesInterface $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);
Expand Down Expand Up @@ -67,6 +67,9 @@ 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);

Expand Down Expand Up @@ -95,15 +98,14 @@ 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;
}

$e = $registry->getEntity($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()));
Expand Down Expand Up @@ -151,14 +153,28 @@ 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;
}

private function resolveTarget(Registry $registry, string $name): ?string
{
if (interface_exists($name, true)) {
if (\interface_exists($name, true)) {
// do not resolve interfaces
return $name;
}
Expand All @@ -176,4 +192,25 @@ private function resolveTarget(Registry $registry, string $name): ?string

return $registry->getEntity($name)->getRole();
}

/**
* @param array<non-empty-string> $columns
*
* @throws AnnotationException
*
* @return array<non-empty-string>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Cycle\Annotated\Tests\Fixtures\Fixtures24\Class\DatabaseField;

use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\ForeignKey;

/**
* @Entity(role="from", table="from")
* @ForeignKey(target="target", outerKey="outer_key", innerKey="inner_key")
*/
#[ForeignKey(target: Target::class, innerKey: 'inner_key', outerKey: 'outer_key')]
#[Entity(role: 'from', table: 'from')]
class DatabaseField
{
/**
* @Column(type="primary")
*/
#[Column(type: 'primary')]
public int $id;

/**
* @Column(type="integer", name="inner_key")
*/
#[Column(type: 'integer', name: 'inner_key')]
public int $innerKey;
}
27 changes: 27 additions & 0 deletions tests/Annotated/Fixtures/Fixtures24/Class/DatabaseField/Target.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Cycle\Annotated\Tests\Fixtures\Fixtures24\Class\DatabaseField;

use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;

/**
* @Entity(table="to")
*/
#[Entity(table: 'to')]
class Target
{
/**
* @Column(type="primary")
*/
#[Column(type: 'primary')]
public int $id;

/**
* @Column(type="integer", name="outer_key")
*/
#[Column(type: 'integer', name: 'outer_key')]
public int $outerKey;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Cycle\Annotated\Tests\Fixtures\Fixtures24\Class\PrimaryKey;

use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\ForeignKey;

/**
* @Entity(role="from", table="from")
* @ForeignKey(target="target", outerKey="id", innerKey="inner_key")
*/
#[ForeignKey(target: Target::class, outerKey: 'id', innerKey: 'inner_key')]
#[Entity(role: 'from', table: 'from')]
class PrimaryKey
{
/**
* @Column(type="primary")
*/
#[Column(type: 'primary')]
public int $id;

/**
* @Column(type="integer", name="inner_key")
*/
#[Column(type: 'integer', name: 'inner_key')]
public int $innerKey;
}
Loading

0 comments on commit bb376d2

Please sign in to comment.