Skip to content

Commit 5e4df24

Browse files
authored
feat(database): add explicit relation attributes (#874)
1 parent 3279ac3 commit 5e4df24

File tree

9 files changed

+309
-21
lines changed

9 files changed

+309
-21
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final readonly class BelongsTo
11+
{
12+
public function __construct(public string $localPropertyName, public string $inversePropertyName = 'id')
13+
{
14+
}
15+
}

src/Tempest/Database/src/Builder/ModelDefinition.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
namespace Tempest\Database\Builder;
66

7+
use Tempest\Database\BelongsTo;
78
use Tempest\Database\Builder\Relations\BelongsToRelation;
89
use Tempest\Database\Builder\Relations\HasManyRelation;
910
use Tempest\Database\Builder\Relations\HasOneRelation;
1011
use Tempest\Database\Eager;
12+
use Tempest\Database\HasMany;
1113
use Tempest\Database\HasOne;
1214
use Tempest\Reflection\ClassReflector;
1315
use function Tempest\reflect;
@@ -31,16 +33,28 @@ public function getRelations(string $relationName): array
3133
foreach ($relationNames as $relationNamePart) {
3234
$property = $class->getProperty($relationNamePart);
3335

34-
if ($property->getType()->isIterable()) {
35-
$relations[] = new HasManyRelation($property, $alias);
36+
if ($property->hasAttribute(HasMany::class)) {
37+
/** @var HasMany $relationAttribute */
38+
$relationAttribute = $property->getAttribute(HasMany::class);
39+
$relations[] = HasManyRelation::fromAttribute($relationAttribute, $property, $alias);
40+
$class = HasManyRelation::getRelationModelClass($property, $relationAttribute)->getType()->asClass();
41+
$alias .= ".{$property->getName()}";
42+
} elseif ($property->getType()->isIterable()) {
43+
$relations[] = HasManyRelation::fromInference($property, $alias);
3644
$class = $property->getIterableType()->asClass();
3745
$alias .= ".{$property->getName()}[]";
3846
} elseif ($property->hasAttribute(HasOne::class)) {
3947
$relations[] = new HasOneRelation($property, $alias);
4048
$class = $property->getType()->asClass();
4149
$alias .= ".{$property->getName()}";
50+
} elseif ($property->hasAttribute(BelongsTo::class)) {
51+
/** @var BelongsTo $relationAttribute */
52+
$relationAttribute = $property->getAttribute(BelongsTo::class);
53+
$relations[] = BelongsToRelation::fromAttribute($relationAttribute, $property, $alias);
54+
$class = $property->getType()->asClass();
55+
$alias .= ".{$property->getName()}";
4256
} else {
43-
$relations[] = new BelongsToRelation($property, $alias);
57+
$relations[] = BelongsToRelation::fromInference($property, $alias);
4458
$class = $property->getType()->asClass();
4559
$alias .= ".{$property->getName()}";
4660
}

src/Tempest/Database/src/Builder/Relations/BelongsToRelation.php

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,45 @@
44

55
namespace Tempest\Database\Builder\Relations;
66

7+
use Tempest\Database\BelongsTo;
78
use Tempest\Database\Builder\FieldName;
89
use Tempest\Database\Builder\TableName;
910
use Tempest\Reflection\ClassReflector;
1011
use Tempest\Reflection\PropertyReflector;
1112

1213
final readonly class BelongsToRelation implements Relation
1314
{
14-
private ClassReflector $relationModelClass;
15+
private function __construct(
16+
private ClassReflector $relationModelClass,
17+
private FieldName $localField,
18+
private FieldName $joinField,
19+
) {
20+
}
21+
22+
public static function fromInference(PropertyReflector $property, string $alias): self
23+
{
24+
$relationModelClass = $property->getType()->asClass();
25+
26+
$localTable = TableName::for($property->getClass(), $alias);
27+
$localField = new FieldName($localTable, $property->getName() . '_id');
1528

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

18-
private FieldName $joinField;
32+
return new self($relationModelClass, $localField, $joinField);
33+
}
1934

20-
public function __construct(PropertyReflector $property, string $alias)
35+
public static function fromAttribute(BelongsTo $belongsTo, PropertyReflector $property, string $alias): self
2136
{
22-
$this->relationModelClass = $property->getType()->asClass();
37+
$relationModelClass = $property->getType()->asClass();
2338

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

2742
$joinTable = TableName::for($property->getType()->asClass(), "{$alias}.{$property->getName()}");
28-
$this->joinField = new FieldName($joinTable, 'id');
43+
$joinField = new FieldName($joinTable, $belongsTo->inversePropertyName);
44+
45+
return new self($relationModelClass, $localField, $joinField);
2946
}
3047

3148
public function getStatement(): string

src/Tempest/Database/src/Builder/Relations/HasManyRelation.php

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,73 @@
66

77
use Tempest\Database\Builder\FieldName;
88
use Tempest\Database\Builder\TableName;
9+
use Tempest\Database\Exceptions\InvalidRelation;
10+
use Tempest\Database\HasMany;
911
use Tempest\Reflection\ClassReflector;
1012
use Tempest\Reflection\PropertyReflector;
1113

1214
final readonly class HasManyRelation implements Relation
1315
{
14-
private ClassReflector $relationModelClass;
15-
16-
private FieldName $localField;
17-
18-
private FieldName $joinField;
16+
private function __construct(
17+
private ClassReflector $relationModelClass,
18+
private FieldName $localField,
19+
private FieldName $joinField,
20+
) {
21+
}
1922

20-
public function __construct(PropertyReflector $property, string $alias)
23+
public static function fromInference(PropertyReflector $property, string $alias): self
2124
{
22-
$this->relationModelClass = $property->getIterableType()->asClass();
25+
$relationModelClass = self::getRelationModelClass($property);
2326

2427
$inverseProperty = null;
2528

26-
foreach ($this->relationModelClass->getPublicProperties() as $potentialInverseProperty) {
29+
foreach ($relationModelClass->getPublicProperties() as $potentialInverseProperty) {
2730
if ($potentialInverseProperty->getType()->equals($property->getClass()->getType())) {
2831
$inverseProperty = $potentialInverseProperty;
2932

3033
break;
3134
}
3235
}
3336

37+
if ($inverseProperty === null) {
38+
throw InvalidRelation::inversePropertyNotFound(
39+
$property->getClass()->getName(),
40+
$property->getName(),
41+
$relationModelClass->getName(),
42+
);
43+
}
44+
3445
$localTable = TableName::for($property->getClass(), $alias);
35-
$this->localField = new FieldName($localTable, 'id');
46+
$localField = new FieldName($localTable, 'id');
47+
48+
$joinTable = TableName::for($relationModelClass, "{$alias}.{$property->getName()}[]");
49+
$joinField = new FieldName($joinTable, $inverseProperty->getName() . '_id');
50+
51+
return new self($relationModelClass, $localField, $joinField);
52+
}
53+
54+
public static function getRelationModelClass(
55+
PropertyReflector $property,
56+
HasMany|null $relation = null,
57+
): ClassReflector {
58+
if ($relation !== null && $relation->inverseClassName !== null) {
59+
return new ClassReflector($relation->inverseClassName);
60+
}
61+
62+
return $property->getIterableType()->asClass();
63+
}
64+
65+
public static function fromAttribute(HasMany $relation, PropertyReflector $property, string $alias): self
66+
{
67+
$relationModelClass = self::getRelationModelClass($property, $relation);
68+
69+
$localTable = TableName::for($property->getClass(), $alias);
70+
$localField = new FieldName($localTable, $relation->localPropertyName);
71+
72+
$joinTable = TableName::for($relationModelClass, "{$alias}.{$property->getName()}[]");
73+
$joinField = new FieldName($joinTable, $relation->inversePropertyName);
3674

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

4178
public function getStatement(): string
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final readonly class HasMany
11+
{
12+
/** @param null|class-string $inverseClassName */
13+
public function __construct(
14+
public string $inversePropertyName,
15+
public ?string $inverseClassName = null,
16+
public string $localPropertyName = 'id',
17+
) {
18+
}
19+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Tests\Relations;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Tempest\Database\Builder\ModelDefinition;
9+
use Tempest\Database\Tests\Relations\Fixtures\BelongsToParentModel;
10+
11+
/**
12+
* @internal
13+
*/
14+
final class BelongsToRelationTest extends TestCase
15+
{
16+
public function test_inferred_belongs_to_relation(): void
17+
{
18+
$definition = new ModelDefinition(BelongsToParentModel::class);
19+
$inferredRelation = $definition->getRelations('relatedModel');
20+
21+
$this->assertCount(1, $inferredRelation);
22+
$this->assertSame('belongs_to_parent_model.relatedModel', $inferredRelation[0]->getRelationName());
23+
$this->assertEquals(
24+
'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.relatedModel`' .
25+
' ON `belongs_to_parent_model`.`relatedModel_id` = `belongs_to_parent_model.relatedModel`.`id`',
26+
$inferredRelation[0]->getStatement(),
27+
);
28+
}
29+
30+
public function test_attribute_with_default_belongs_to_relation(): void
31+
{
32+
$definition = new ModelDefinition(BelongsToParentModel::class);
33+
$namedRelation = $definition->getRelations('otherRelatedModel');
34+
35+
$this->assertCount(1, $namedRelation);
36+
37+
$this->assertSame('belongs_to_parent_model.otherRelatedModel', $namedRelation[0]->getRelationName());
38+
$this->assertEquals(
39+
'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.otherRelatedModel`' .
40+
' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.otherRelatedModel`.`id`',
41+
$namedRelation[0]->getStatement(),
42+
);
43+
}
44+
45+
public function test_attribute_belongs_to_relation(): void
46+
{
47+
$definition = new ModelDefinition(BelongsToParentModel::class);
48+
$doublyNamedRelation = $definition->getRelations('stillOtherRelatedModel');
49+
50+
$this->assertCount(1, $doublyNamedRelation);
51+
52+
$this->assertSame('belongs_to_parent_model.stillOtherRelatedModel', $doublyNamedRelation[0]->getRelationName());
53+
$this->assertEquals(
54+
'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.stillOtherRelatedModel`' .
55+
' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.stillOtherRelatedModel`.`other_id`',
56+
$doublyNamedRelation[0]->getStatement(),
57+
);
58+
}
59+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Tests\Relations\Fixtures;
6+
7+
use Tempest\Database\BelongsTo;
8+
use Tempest\Database\Builder\TableName;
9+
use Tempest\Database\DatabaseModel;
10+
use Tempest\Database\IsDatabaseModel;
11+
12+
final class BelongsToParentModel implements DatabaseModel
13+
{
14+
use IsDatabaseModel;
15+
16+
public static function table(): TableName
17+
{
18+
return new TableName('belongs_to_parent_model');
19+
}
20+
21+
public BelongsToRelatedModel $relatedModel;
22+
23+
#[BelongsTo('other_id')]
24+
public BelongsToRelatedModel $otherRelatedModel;
25+
26+
#[BelongsTo('other_id', 'other_id')]
27+
public BelongsToRelatedModel $stillOtherRelatedModel;
28+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Tests\Relations\Fixtures;
6+
7+
use Tempest\Database\Builder\TableName;
8+
use Tempest\Database\DatabaseModel;
9+
use Tempest\Database\HasMany;
10+
use Tempest\Database\IsDatabaseModel;
11+
12+
final class BelongsToRelatedModel implements DatabaseModel
13+
{
14+
use IsDatabaseModel;
15+
16+
/** @var \Tempest\Database\Tests\Relations\Fixtures\BelongsToParentModel[] */
17+
public array $inferred = [];
18+
19+
#[HasMany('other_id')]
20+
/** @var \Tempest\Database\Tests\Relations\Fixtures\BelongsToParentModel[] */
21+
public array $attribute = [];
22+
23+
#[HasMany('other_id', BelongsToParentModel::class, 'other_id')]
24+
public array $full = [];
25+
26+
/** @var \Tempest\Database\Tests\Relations\Fixtures\HasOneParentModel[] */
27+
public array $invalid = [];
28+
29+
public static function table(): TableName
30+
{
31+
return new TableName('belongs_to_related');
32+
}
33+
}

0 commit comments

Comments
 (0)