Skip to content

Commit

Permalink
Add the ability to add foreign key via attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
msmakouz committed Nov 1, 2023
1 parent 42ab4a2 commit 036896e
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 25 deletions.
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
44 changes: 44 additions & 0 deletions src/Annotation/ForeignKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?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
{
public function __construct(
public string $target,
protected array|string $outerKey,
protected array|string|null $innerKey = null,
/**
* @Enum({"NO ACTION", "CASCADE", "SET NULL"})
*/
#[ExpectedValues(values: ['NO ACTION', 'CASCADE', 'SET NULL'])]
public string $action = 'CASCADE',
public bool $indexCreate = true,
) {
}

public function getOuterKey(): array
{
return (array) $this->outerKey;
}

public function getInnerKey(): ?array
{
return $this->innerKey === null ? null : (array) $this->innerKey;
}
}
44 changes: 19 additions & 25 deletions src/Entities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand All @@ -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)
);
}
}
Expand All @@ -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)
);
}
}
Expand Down Expand Up @@ -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();
}
}
161 changes: 161 additions & 0 deletions src/ForeignKeysConfigurator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace Cycle\Annotated;

use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\ForeignKey;
use Cycle\Annotated\Exception\AnnotationException;
use Cycle\Annotated\Utils\EntityUtils;
use Cycle\Schema\Definition\Entity as EntitySchema;
use Cycle\Schema\Registry;
use Spiral\Attributes\ReaderInterface;

/**
* @internal
*/
final class ForeignKeysConfigurator
{
/**
* @var array<array{
* entity: EntitySchema,
* foreignKey: ForeignKey
* property?: \ReflectionProperty,
* }>
*/
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<non-empty-string>|null $innerKeys
*
* @return array<non-empty-string>
*
* @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<non-empty-string> $outerKeys
*
* @return array<non-empty-string>
*
* @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,
];
}
}
22 changes: 22 additions & 0 deletions src/Utils/EntityUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}
}

0 comments on commit 036896e

Please sign in to comment.