Skip to content

Commit 5b48400

Browse files
committed
feat(database): add support for self-referencing relations
1 parent 6a3f48f commit 5b48400

File tree

4 files changed

+180
-10
lines changed

4 files changed

+180
-10
lines changed

packages/database/src/BelongsTo.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@ public function getOwnerFieldName(): string
6060
public function getSelectFields(): ImmutableArray
6161
{
6262
$relationModel = inspect($this->property->getType()->asClass());
63+
$tableReference = $this->isSelfReferencing()
64+
? $this->property->getName()
65+
: $relationModel->getTableName();
6366

6467
return $relationModel
6568
->getSelectFields()
66-
->map(function ($field) use ($relationModel) {
69+
->map(function ($field) use ($tableReference) {
6770
return new FieldStatement(
68-
$relationModel->getTableName() . '.' . $field,
71+
$tableReference . '.' . $field,
6972
)
7073
->withAlias(
7174
sprintf('%s.%s', $this->property->getName(), $field),
@@ -82,6 +85,16 @@ public function getJoinStatement(): JoinStatement
8285
$relationJoin = $this->getRelationJoin($relationModel);
8386
$ownerJoin = $this->getOwnerJoin($ownerModel);
8487

88+
if ($this->isSelfReferencing()) {
89+
return new JoinStatement(sprintf(
90+
'LEFT JOIN %s AS %s ON %s = %s',
91+
$relationModel->getTableName(),
92+
$this->property->getName(),
93+
$relationJoin,
94+
$ownerJoin,
95+
));
96+
}
97+
8598
// LEFT JOIN authors ON authors.id = books.author_id
8699
return new JoinStatement(sprintf(
87100
'LEFT JOIN %s ON %s = %s',
@@ -94,9 +107,12 @@ public function getJoinStatement(): JoinStatement
94107
private function getRelationJoin(ModelInspector $relationModel): string
95108
{
96109
$relationJoin = $this->relationJoin;
110+
$tableReference = $this->isSelfReferencing()
111+
? $this->property->getName()
112+
: $relationModel->getTableName();
97113

98114
if ($relationJoin && ! strpos($relationJoin, '.')) {
99-
$relationJoin = sprintf('%s.%s', $relationModel->getTableName(), $relationJoin);
115+
$relationJoin = sprintf('%s.%s', $tableReference, $relationJoin);
100116
}
101117

102118
if ($relationJoin) {
@@ -109,7 +125,15 @@ private function getRelationJoin(ModelInspector $relationModel): string
109125
throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'BelongsTo');
110126
}
111127

112-
return sprintf('%s.%s', $relationModel->getTableName(), $primaryKey);
128+
return sprintf('%s.%s', $tableReference, $primaryKey);
129+
}
130+
131+
private function isSelfReferencing(): bool
132+
{
133+
$relationModel = inspect($this->property->getType()->asClass());
134+
$ownerModel = inspect($this->property->getClass());
135+
136+
return $relationModel->getTableName() === $ownerModel->getTableName();
113137
}
114138

115139
private function getOwnerJoin(ModelInspector $ownerModel): string

packages/database/src/HasMany.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@ public function setParent(string $name): self
4040
public function getSelectFields(): ImmutableArray
4141
{
4242
$relationModel = inspect($this->property->getIterableType()->asClass());
43+
$tableReference = $this->isSelfReferencing()
44+
? $this->property->getName()
45+
: $relationModel->getTableName();
4346

4447
return $relationModel
4548
->getSelectFields()
4649
->map(fn ($field) => new FieldStatement(
47-
$relationModel->getTableName() . '.' . $field,
50+
$tableReference . '.' . $field,
4851
)
4952
->withAlias(
5053
sprintf('%s.%s', $this->property->getName(), $field),
@@ -88,6 +91,16 @@ public function getJoinStatement(): JoinStatement
8891
$ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel);
8992
$relationJoin = $this->getRelationJoin($relationModel);
9093

94+
if ($this->isSelfReferencing()) {
95+
return new JoinStatement(sprintf(
96+
'LEFT JOIN %s AS %s ON %s = %s',
97+
$ownerModel->getTableName(),
98+
$this->property->getName(),
99+
$ownerJoin,
100+
$relationJoin,
101+
));
102+
}
103+
91104
return new JoinStatement(sprintf(
92105
'LEFT JOIN %s ON %s = %s',
93106
$ownerModel->getTableName(),
@@ -99,11 +112,14 @@ public function getJoinStatement(): JoinStatement
99112
private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relationModel): string
100113
{
101114
$ownerJoin = $this->ownerJoin;
115+
$tableReference = $this->isSelfReferencing()
116+
? $this->property->getName()
117+
: $ownerModel->getTableName();
102118

103119
if ($ownerJoin && ! strpos($ownerJoin, '.')) {
104120
$ownerJoin = sprintf(
105121
'%s.%s',
106-
$ownerModel->getTableName(),
122+
$tableReference,
107123
$ownerJoin,
108124
);
109125
}
@@ -120,11 +136,19 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati
120136

121137
return sprintf(
122138
'%s.%s',
123-
$ownerModel->getTableName(),
139+
$tableReference,
124140
str($relationModel->getTableName())->singularizeLastWord() . '_' . $primaryKey,
125141
);
126142
}
127143

144+
private function isSelfReferencing(): bool
145+
{
146+
$relationModel = inspect($this->property->getIterableType()->asClass());
147+
$ownerModel = inspect($this->property->getClass());
148+
149+
return $relationModel->getTableName() === $ownerModel->getTableName();
150+
}
151+
128152
private function getRelationJoin(ModelInspector $relationModel): string
129153
{
130154
$relationJoin = $this->relationJoin;

packages/database/src/HasOne.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@ public function setParent(string $name): self
4040
public function getSelectFields(): ImmutableArray
4141
{
4242
$relationModel = inspect($this->property->getType()->asClass());
43+
$tableReference = $this->isSelfReferencing()
44+
? $this->property->getName()
45+
: $relationModel->getTableName();
4346

4447
return $relationModel
4548
->getSelectFields()
4649
->map(fn ($field) => new FieldStatement(
47-
$relationModel->getTableName() . '.' . $field,
50+
$tableReference . '.' . $field,
4851
)
4952
->withAlias(
5053
sprintf('%s.%s', $this->property->getName(), $field),
@@ -60,6 +63,16 @@ public function getJoinStatement(): JoinStatement
6063
$ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel);
6164
$relationJoin = $this->getRelationJoin($relationModel);
6265

66+
if ($this->isSelfReferencing()) {
67+
return new JoinStatement(sprintf(
68+
'LEFT JOIN %s AS %s ON %s = %s',
69+
$ownerModel->getTableName(),
70+
$this->property->getName(),
71+
$ownerJoin,
72+
$relationJoin,
73+
));
74+
}
75+
6376
return new JoinStatement(sprintf(
6477
'LEFT JOIN %s ON %s = %s',
6578
$ownerModel->getTableName(),
@@ -71,11 +84,14 @@ public function getJoinStatement(): JoinStatement
7184
private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relationModel): string
7285
{
7386
$ownerJoin = $this->ownerJoin;
87+
$tableReference = $this->isSelfReferencing()
88+
? $this->property->getName()
89+
: $ownerModel->getTableName();
7490

7591
if ($ownerJoin && ! strpos($ownerJoin, '.')) {
7692
$ownerJoin = sprintf(
7793
'%s.%s',
78-
$ownerModel->getTableName(),
94+
$tableReference,
7995
$ownerJoin,
8096
);
8197
}
@@ -92,11 +108,19 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati
92108

93109
return sprintf(
94110
'%s.%s',
95-
$ownerModel->getTableName(),
111+
$tableReference,
96112
str($relationModel->getTableName())->singularizeLastWord() . '_' . $primaryKey,
97113
);
98114
}
99115

116+
private function isSelfReferencing(): bool
117+
{
118+
$relationModel = inspect($this->property->getType()->asClass());
119+
$ownerModel = inspect($this->property->getClass());
120+
121+
return $relationModel->getTableName() === $ownerModel->getTableName();
122+
}
123+
100124
private function getRelationJoin(ModelInspector $relationModel): string
101125
{
102126
$relationJoin = $this->relationJoin;

tests/Integration/Database/ModelInspector/BelongsToTest.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,84 @@ public function test_belongs_to_throws_exception_for_model_without_primary_key()
102102

103103
$relation->getJoinStatement();
104104
}
105+
106+
public function test_self_referencing_belongs_to(): void
107+
{
108+
$model = inspect(SelfReferencingCategoryModel::class);
109+
$relation = $model->getRelation('parent');
110+
111+
$this->assertInstanceOf(BelongsTo::class, $relation);
112+
113+
$this->assertEquals(
114+
'LEFT JOIN categories AS parent ON parent.id = categories.parent_id',
115+
$relation->getJoinStatement()->compile(DatabaseDialect::SQLITE),
116+
);
117+
}
118+
119+
public function test_self_referencing_belongs_to_select_fields(): void
120+
{
121+
$model = inspect(SelfReferencingCategoryModel::class);
122+
$relation = $model->getRelation('parent');
123+
124+
$selectFields = $relation->getSelectFields();
125+
126+
$this->assertSame(
127+
'parent.id AS `parent.id`',
128+
$selectFields[0]->compile(DatabaseDialect::SQLITE),
129+
);
130+
131+
$this->assertSame(
132+
'parent.parent_id AS `parent.parent_id`',
133+
$selectFields[1]->compile(DatabaseDialect::SQLITE),
134+
);
135+
136+
$this->assertSame(
137+
'parent.name AS `parent.name`',
138+
$selectFields[2]->compile(DatabaseDialect::SQLITE),
139+
);
140+
}
141+
142+
public function test_self_referencing_belongs_to_with_custom_owner_join(): void
143+
{
144+
$model = inspect(SelfReferencingCategoryModel::class);
145+
$relation = $model->getRelation('parentWithCustomOwnerJoin');
146+
147+
$this->assertEquals(
148+
'LEFT JOIN categories AS parentWithCustomOwnerJoin ON parentWithCustomOwnerJoin.id = categories.category_parent_id',
149+
$relation->getJoinStatement()->compile(DatabaseDialect::SQLITE),
150+
);
151+
}
152+
153+
public function test_self_referencing_has_many(): void
154+
{
155+
$model = inspect(SelfReferencingCategoryModel::class);
156+
$relation = $model->getRelation('children');
157+
158+
$this->assertInstanceOf(HasMany::class, $relation);
159+
160+
$this->assertEquals(
161+
'LEFT JOIN categories AS children ON children.parent_id = categories.id',
162+
$relation->getJoinStatement()->compile(DatabaseDialect::SQLITE),
163+
);
164+
}
165+
166+
public function test_self_referencing_has_many_select_fields(): void
167+
{
168+
$model = inspect(SelfReferencingCategoryModel::class);
169+
$relation = $model->getRelation('children');
170+
171+
$selectFields = $relation->getSelectFields();
172+
173+
$this->assertSame(
174+
'children.id AS `children.id`',
175+
$selectFields[0]->compile(DatabaseDialect::SQLITE),
176+
);
177+
178+
$this->assertSame(
179+
'children.parent_id AS `children.parent_id`',
180+
$selectFields[1]->compile(DatabaseDialect::SQLITE),
181+
);
182+
}
105183
}
106184

107185
#[Table('relation')]
@@ -168,3 +246,23 @@ final class BelongsToTestOwnerWithoutIdModel
168246

169247
public string $name;
170248
}
249+
250+
#[Table('categories')]
251+
final class SelfReferencingCategoryModel
252+
{
253+
public PrimaryKey $id;
254+
255+
public ?int $parent_id = null;
256+
257+
public string $name;
258+
259+
#[BelongsTo(ownerJoin: 'parent_id', relationJoin: 'id')]
260+
public ?SelfReferencingCategoryModel $parent = null;
261+
262+
#[BelongsTo(ownerJoin: 'category_parent_id', relationJoin: 'id')]
263+
public ?SelfReferencingCategoryModel $parentWithCustomOwnerJoin = null;
264+
265+
/** @var \Tests\Tempest\Integration\Database\ModelInspector\SelfReferencingCategoryModel[] */
266+
#[HasMany(ownerJoin: 'parent_id', relationJoin: 'id')]
267+
public array $children = [];
268+
}

0 commit comments

Comments
 (0)