Skip to content

Commit

Permalink
feat(database): add explicit relation attributes (#874)
Browse files Browse the repository at this point in the history
  • Loading branch information
blackshadev authored Jan 9, 2025
1 parent 3279ac3 commit 5e4df24
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 21 deletions.
15 changes: 15 additions & 0 deletions src/Tempest/Database/src/BelongsTo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class BelongsTo
{
public function __construct(public string $localPropertyName, public string $inversePropertyName = 'id')
{
}
}
20 changes: 17 additions & 3 deletions src/Tempest/Database/src/Builder/ModelDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace Tempest\Database\Builder;

use Tempest\Database\BelongsTo;
use Tempest\Database\Builder\Relations\BelongsToRelation;
use Tempest\Database\Builder\Relations\HasManyRelation;
use Tempest\Database\Builder\Relations\HasOneRelation;
use Tempest\Database\Eager;
use Tempest\Database\HasMany;
use Tempest\Database\HasOne;
use Tempest\Reflection\ClassReflector;
use function Tempest\reflect;
Expand All @@ -31,16 +33,28 @@ public function getRelations(string $relationName): array
foreach ($relationNames as $relationNamePart) {
$property = $class->getProperty($relationNamePart);

if ($property->getType()->isIterable()) {
$relations[] = new HasManyRelation($property, $alias);
if ($property->hasAttribute(HasMany::class)) {
/** @var HasMany $relationAttribute */
$relationAttribute = $property->getAttribute(HasMany::class);
$relations[] = HasManyRelation::fromAttribute($relationAttribute, $property, $alias);
$class = HasManyRelation::getRelationModelClass($property, $relationAttribute)->getType()->asClass();
$alias .= ".{$property->getName()}";
} elseif ($property->getType()->isIterable()) {
$relations[] = HasManyRelation::fromInference($property, $alias);
$class = $property->getIterableType()->asClass();
$alias .= ".{$property->getName()}[]";
} elseif ($property->hasAttribute(HasOne::class)) {
$relations[] = new HasOneRelation($property, $alias);
$class = $property->getType()->asClass();
$alias .= ".{$property->getName()}";
} elseif ($property->hasAttribute(BelongsTo::class)) {
/** @var BelongsTo $relationAttribute */
$relationAttribute = $property->getAttribute(BelongsTo::class);
$relations[] = BelongsToRelation::fromAttribute($relationAttribute, $property, $alias);
$class = $property->getType()->asClass();
$alias .= ".{$property->getName()}";
} else {
$relations[] = new BelongsToRelation($property, $alias);
$relations[] = BelongsToRelation::fromInference($property, $alias);
$class = $property->getType()->asClass();
$alias .= ".{$property->getName()}";
}
Expand Down
31 changes: 24 additions & 7 deletions src/Tempest/Database/src/Builder/Relations/BelongsToRelation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,45 @@

namespace Tempest\Database\Builder\Relations;

use Tempest\Database\BelongsTo;
use Tempest\Database\Builder\FieldName;
use Tempest\Database\Builder\TableName;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\PropertyReflector;

final readonly class BelongsToRelation implements Relation
{
private ClassReflector $relationModelClass;
private function __construct(
private ClassReflector $relationModelClass,
private FieldName $localField,
private FieldName $joinField,
) {
}

public static function fromInference(PropertyReflector $property, string $alias): self
{
$relationModelClass = $property->getType()->asClass();

$localTable = TableName::for($property->getClass(), $alias);
$localField = new FieldName($localTable, $property->getName() . '_id');

private FieldName $localField;
$joinTable = TableName::for($property->getType()->asClass(), "{$alias}.{$property->getName()}");
$joinField = new FieldName($joinTable, 'id');

private FieldName $joinField;
return new self($relationModelClass, $localField, $joinField);
}

public function __construct(PropertyReflector $property, string $alias)
public static function fromAttribute(BelongsTo $belongsTo, PropertyReflector $property, string $alias): self
{
$this->relationModelClass = $property->getType()->asClass();
$relationModelClass = $property->getType()->asClass();

$localTable = TableName::for($property->getClass(), $alias);
$this->localField = new FieldName($localTable, $property->getName() . '_id');
$localField = new FieldName($localTable, $belongsTo->localPropertyName);

$joinTable = TableName::for($property->getType()->asClass(), "{$alias}.{$property->getName()}");
$this->joinField = new FieldName($joinTable, 'id');
$joinField = new FieldName($joinTable, $belongsTo->inversePropertyName);

return new self($relationModelClass, $localField, $joinField);
}

public function getStatement(): string
Expand Down
59 changes: 48 additions & 11 deletions src/Tempest/Database/src/Builder/Relations/HasManyRelation.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,73 @@

use Tempest\Database\Builder\FieldName;
use Tempest\Database\Builder\TableName;
use Tempest\Database\Exceptions\InvalidRelation;
use Tempest\Database\HasMany;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\PropertyReflector;

final readonly class HasManyRelation implements Relation
{
private ClassReflector $relationModelClass;

private FieldName $localField;

private FieldName $joinField;
private function __construct(
private ClassReflector $relationModelClass,
private FieldName $localField,
private FieldName $joinField,
) {
}

public function __construct(PropertyReflector $property, string $alias)
public static function fromInference(PropertyReflector $property, string $alias): self
{
$this->relationModelClass = $property->getIterableType()->asClass();
$relationModelClass = self::getRelationModelClass($property);

$inverseProperty = null;

foreach ($this->relationModelClass->getPublicProperties() as $potentialInverseProperty) {
foreach ($relationModelClass->getPublicProperties() as $potentialInverseProperty) {
if ($potentialInverseProperty->getType()->equals($property->getClass()->getType())) {
$inverseProperty = $potentialInverseProperty;

break;
}
}

if ($inverseProperty === null) {
throw InvalidRelation::inversePropertyNotFound(
$property->getClass()->getName(),
$property->getName(),
$relationModelClass->getName(),
);
}

$localTable = TableName::for($property->getClass(), $alias);
$this->localField = new FieldName($localTable, 'id');
$localField = new FieldName($localTable, 'id');

$joinTable = TableName::for($relationModelClass, "{$alias}.{$property->getName()}[]");
$joinField = new FieldName($joinTable, $inverseProperty->getName() . '_id');

return new self($relationModelClass, $localField, $joinField);
}

public static function getRelationModelClass(
PropertyReflector $property,
HasMany|null $relation = null,
): ClassReflector {
if ($relation !== null && $relation->inverseClassName !== null) {
return new ClassReflector($relation->inverseClassName);
}

return $property->getIterableType()->asClass();
}

public static function fromAttribute(HasMany $relation, PropertyReflector $property, string $alias): self
{
$relationModelClass = self::getRelationModelClass($property, $relation);

$localTable = TableName::for($property->getClass(), $alias);
$localField = new FieldName($localTable, $relation->localPropertyName);

$joinTable = TableName::for($relationModelClass, "{$alias}.{$property->getName()}[]");
$joinField = new FieldName($joinTable, $relation->inversePropertyName);

$joinTable = TableName::for($this->relationModelClass, "{$alias}.{$property->getName()}[]");
$this->joinField = new FieldName($joinTable, $inverseProperty->getName() . '_id');
return new self($relationModelClass, $localField, $joinField);
}

public function getStatement(): string
Expand Down
19 changes: 19 additions & 0 deletions src/Tempest/Database/src/HasMany.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class HasMany
{
/** @param null|class-string $inverseClassName */
public function __construct(
public string $inversePropertyName,
public ?string $inverseClassName = null,
public string $localPropertyName = 'id',
) {
}
}
59 changes: 59 additions & 0 deletions src/Tempest/Database/tests/Relations/BelongsToRelationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Tests\Relations;

use PHPUnit\Framework\TestCase;
use Tempest\Database\Builder\ModelDefinition;
use Tempest\Database\Tests\Relations\Fixtures\BelongsToParentModel;

/**
* @internal
*/
final class BelongsToRelationTest extends TestCase
{
public function test_inferred_belongs_to_relation(): void
{
$definition = new ModelDefinition(BelongsToParentModel::class);
$inferredRelation = $definition->getRelations('relatedModel');

$this->assertCount(1, $inferredRelation);
$this->assertSame('belongs_to_parent_model.relatedModel', $inferredRelation[0]->getRelationName());
$this->assertEquals(
'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.relatedModel`' .
' ON `belongs_to_parent_model`.`relatedModel_id` = `belongs_to_parent_model.relatedModel`.`id`',
$inferredRelation[0]->getStatement(),
);
}

public function test_attribute_with_default_belongs_to_relation(): void
{
$definition = new ModelDefinition(BelongsToParentModel::class);
$namedRelation = $definition->getRelations('otherRelatedModel');

$this->assertCount(1, $namedRelation);

$this->assertSame('belongs_to_parent_model.otherRelatedModel', $namedRelation[0]->getRelationName());
$this->assertEquals(
'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.otherRelatedModel`' .
' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.otherRelatedModel`.`id`',
$namedRelation[0]->getStatement(),
);
}

public function test_attribute_belongs_to_relation(): void
{
$definition = new ModelDefinition(BelongsToParentModel::class);
$doublyNamedRelation = $definition->getRelations('stillOtherRelatedModel');

$this->assertCount(1, $doublyNamedRelation);

$this->assertSame('belongs_to_parent_model.stillOtherRelatedModel', $doublyNamedRelation[0]->getRelationName());
$this->assertEquals(
'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.stillOtherRelatedModel`' .
' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.stillOtherRelatedModel`.`other_id`',
$doublyNamedRelation[0]->getStatement(),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Tests\Relations\Fixtures;

use Tempest\Database\BelongsTo;
use Tempest\Database\Builder\TableName;
use Tempest\Database\DatabaseModel;
use Tempest\Database\IsDatabaseModel;

final class BelongsToParentModel implements DatabaseModel
{
use IsDatabaseModel;

public static function table(): TableName
{
return new TableName('belongs_to_parent_model');
}

public BelongsToRelatedModel $relatedModel;

#[BelongsTo('other_id')]
public BelongsToRelatedModel $otherRelatedModel;

#[BelongsTo('other_id', 'other_id')]
public BelongsToRelatedModel $stillOtherRelatedModel;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Tests\Relations\Fixtures;

use Tempest\Database\Builder\TableName;
use Tempest\Database\DatabaseModel;
use Tempest\Database\HasMany;
use Tempest\Database\IsDatabaseModel;

final class BelongsToRelatedModel implements DatabaseModel
{
use IsDatabaseModel;

/** @var \Tempest\Database\Tests\Relations\Fixtures\BelongsToParentModel[] */
public array $inferred = [];

#[HasMany('other_id')]
/** @var \Tempest\Database\Tests\Relations\Fixtures\BelongsToParentModel[] */
public array $attribute = [];

#[HasMany('other_id', BelongsToParentModel::class, 'other_id')]
public array $full = [];

/** @var \Tempest\Database\Tests\Relations\Fixtures\HasOneParentModel[] */
public array $invalid = [];

public static function table(): TableName
{
return new TableName('belongs_to_related');
}
}
Loading

0 comments on commit 5e4df24

Please sign in to comment.