Skip to content

Commit ddd4e55

Browse files
committed
Many-to-many Relationships: Initial implementation
1 parent b33204f commit ddd4e55

File tree

12 files changed

+1317
-11
lines changed

12 files changed

+1317
-11
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database;
6+
7+
use Attribute;
8+
use Tempest\Database\Builder\ModelInspector;
9+
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
10+
use Tempest\Database\QueryStatements\FieldStatement;
11+
use Tempest\Database\QueryStatements\JoinStatement;
12+
use Tempest\Reflection\PropertyReflector;
13+
use Tempest\Support\Arr\ImmutableArray;
14+
15+
use function Tempest\Support\arr;
16+
use function Tempest\Support\str;
17+
18+
#[Attribute(Attribute::TARGET_PROPERTY)]
19+
final class BelongsToMany implements Relation
20+
{
21+
public PropertyReflector $property;
22+
23+
public string $name {
24+
get => $this->property->getName();
25+
}
26+
27+
private ?string $parent = null;
28+
29+
public function __construct(
30+
private readonly ?string $pivotTable = null,
31+
private readonly ?string $pivotCurrentKey = null,
32+
private readonly ?string $pivotRelatedKey = null,
33+
private readonly array $pivotFields = [],
34+
) {}
35+
36+
public function setParent(string $name): self
37+
{
38+
$this->parent = $name;
39+
40+
return $this;
41+
}
42+
43+
public function getSelectFields(): ImmutableArray
44+
{
45+
$relatedModel = inspect($this->property->getIterableType()->asClass());
46+
47+
$fields = $relatedModel
48+
->getSelectFields()
49+
->map(fn ($field) => new FieldStatement(
50+
$relatedModel->getTableName() . '.' . $field,
51+
)
52+
->withAlias(
53+
sprintf('%s.%s', $this->property->getName(), $field),
54+
)
55+
->withAliasPrefix($this->parent));
56+
57+
if ($this->pivotFields) {
58+
$pivotTable = $this->getPivotTableName(
59+
inspect($this->property->getClass()),
60+
$relatedModel,
61+
);
62+
63+
foreach ($this->pivotFields as $pivotField) {
64+
$fields[] = new FieldStatement(
65+
sprintf('%s.%s', $pivotTable, $pivotField),
66+
)
67+
->withAlias(
68+
sprintf('%s.pivot.%s', $this->property->getName(), $pivotField),
69+
)
70+
->withAliasPrefix($this->parent);
71+
}
72+
}
73+
74+
return arr($fields);
75+
}
76+
77+
public function primaryKey(): string
78+
{
79+
$relatedModel = inspect($this->property->getIterableType()->asClass());
80+
$primaryKey = $relatedModel->getPrimaryKey();
81+
82+
if ($primaryKey === null) {
83+
throw ModelDidNotHavePrimaryColumn::neededForRelation($relatedModel->getName(), 'BelongsToMany');
84+
}
85+
86+
return $primaryKey;
87+
}
88+
89+
public function idField(): string
90+
{
91+
return sprintf(
92+
'%s.%s',
93+
$this->property->getName(),
94+
$this->primaryKey(),
95+
);
96+
}
97+
98+
public function getJoinStatement(): JoinStatement
99+
{
100+
$currentModel = inspect($this->property->getClass());
101+
$relatedModel = inspect($this->property->getIterableType()->asClass());
102+
103+
$pivotTable = $this->getPivotTableName($currentModel, $relatedModel);
104+
105+
$currentPrimaryKey = $currentModel->getPrimaryKey();
106+
if ($currentPrimaryKey === null) {
107+
throw ModelDidNotHavePrimaryColumn::neededForRelation($currentModel->getName(), 'BelongsToMany');
108+
}
109+
110+
$relatedPrimaryKey = $relatedModel->getPrimaryKey();
111+
if ($relatedPrimaryKey === null) {
112+
throw ModelDidNotHavePrimaryColumn::neededForRelation($relatedModel->getName(), 'BelongsToMany');
113+
}
114+
115+
$pivotCurrentKey = $this->getPivotCurrentKey($currentModel, $pivotTable);
116+
$pivotRelatedKey = $this->getPivotRelatedKey($relatedModel, $pivotTable);
117+
118+
return new JoinStatement(sprintf(
119+
'LEFT JOIN %s ON %s.%s = %s.%s LEFT JOIN %s ON %s.%s = %s.%s',
120+
$pivotTable,
121+
$pivotTable,
122+
$pivotCurrentKey,
123+
$currentModel->getTableName(),
124+
$currentPrimaryKey,
125+
$relatedModel->getTableName(),
126+
$relatedModel->getTableName(),
127+
$relatedPrimaryKey,
128+
$pivotTable,
129+
$pivotRelatedKey,
130+
));
131+
}
132+
133+
private function getPivotTableName(ModelInspector $currentModel, ModelInspector $relatedModel): string
134+
{
135+
if ($this->pivotTable !== null) {
136+
return $this->pivotTable;
137+
}
138+
139+
// Default: alphabetical order of singularized table names
140+
$currentSingular = str($currentModel->getTableName())->singularizeLastWord()->toString();
141+
$relatedSingular = str($relatedModel->getTableName())->singularizeLastWord()->toString();
142+
143+
$tables = [$currentSingular, $relatedSingular];
144+
sort($tables);
145+
146+
return implode('_', $tables);
147+
}
148+
149+
private function getPivotCurrentKey(ModelInspector $currentModel, string $pivotTable): string
150+
{
151+
if ($this->pivotCurrentKey !== null) {
152+
return $this->qualifyPivotKey($this->pivotCurrentKey, $pivotTable);
153+
}
154+
155+
$primaryKey = $currentModel->getPrimaryKey();
156+
if ($primaryKey === null) {
157+
throw ModelDidNotHavePrimaryColumn::neededForRelation($currentModel->getName(), 'BelongsToMany');
158+
}
159+
160+
return str($currentModel->getTableName())->singularizeLastWord() . '_' . $primaryKey;
161+
}
162+
163+
private function getPivotRelatedKey(ModelInspector $relatedModel, string $pivotTable): string
164+
{
165+
if ($this->pivotRelatedKey !== null) {
166+
return $this->qualifyPivotKey($this->pivotRelatedKey, $pivotTable);
167+
}
168+
169+
$primaryKey = $relatedModel->getPrimaryKey();
170+
if ($primaryKey === null) {
171+
throw ModelDidNotHavePrimaryColumn::neededForRelation($relatedModel->getName(), 'BelongsToMany');
172+
}
173+
174+
return str($relatedModel->getTableName())->singularizeLastWord() . '_' . $primaryKey;
175+
}
176+
177+
private function qualifyPivotKey(string $key, string $pivotTable): string
178+
{
179+
if (! strpos($key, '.')) {
180+
return $key;
181+
}
182+
183+
return sprintf('%s.%s', $pivotTable, $key);
184+
}
185+
}

packages/database/src/Builder/ModelInspector.php

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use ReflectionException;
66
use Tempest\Database\BelongsTo;
7+
use Tempest\Database\BelongsToMany;
78
use Tempest\Database\Config\DatabaseConfig;
89
use Tempest\Database\Eager;
910
use Tempest\Database\HasMany;
@@ -42,6 +43,9 @@ public function __construct(
4243
if ($model instanceof HasMany) {
4344
$model = $model->property->getIterableType()->asClass();
4445
$this->reflector = $model;
46+
} elseif ($model instanceof BelongsToMany) {
47+
$model = $model->property->getIterableType()->asClass();
48+
$this->reflector = $model;
4549
} elseif ($model instanceof BelongsTo || $model instanceof HasOne) {
4650
$model = $model->property->getType()->asClass();
4751
$this->reflector = $model;
@@ -118,7 +122,7 @@ public function getPropertyValues(): array
118122
continue;
119123
}
120124

121-
if ($this->getHasMany($property->getName()) || $this->getHasOne($property->getName())) {
125+
if ($this->getHasMany($property->getName()) || $this->getHasOne($property->getName()) || $this->getBelongsToMany($property->getName())) {
122126
continue;
123127
}
124128

@@ -229,6 +233,10 @@ public function getHasMany(string $name): ?HasMany
229233
return null;
230234
}
231235

236+
if ($property->hasAttribute(BelongsToMany::class)) {
237+
return null;
238+
}
239+
232240
if (! $property->getIterableType()?->isRelation()) {
233241
return null;
234242
}
@@ -239,18 +247,44 @@ public function getHasMany(string $name): ?HasMany
239247
return $hasMany;
240248
}
241249

250+
public function getBelongsToMany(string $name): ?BelongsToMany
251+
{
252+
if (! $this->isObjectModel()) {
253+
return null;
254+
}
255+
256+
$name = str($name)->camel();
257+
258+
if (! $this->reflector->hasProperty($name)) {
259+
return null;
260+
}
261+
262+
$property = $this->reflector->getProperty($name);
263+
264+
if ($property->hasAttribute(Virtual::class)) {
265+
return null;
266+
}
267+
268+
if ($belongsToMany = $property->getAttribute(BelongsToMany::class)) {
269+
$belongsToMany->property = $property;
270+
return $belongsToMany;
271+
}
272+
273+
return null;
274+
}
275+
242276
public function isRelation(string|PropertyReflector $name): bool
243277
{
244278
$name = ($name instanceof PropertyReflector) ? $name->getName() : $name;
245279

246-
return $this->getBelongsTo($name) !== null || $this->getHasOne($name) !== null || $this->getHasMany($name) !== null;
280+
return $this->getBelongsTo($name) !== null || $this->getHasOne($name) !== null || $this->getBelongsToMany($name) !== null || $this->getHasMany($name) !== null;
247281
}
248282

249283
public function getRelation(string|PropertyReflector $name): ?Relation
250284
{
251285
$name = ($name instanceof PropertyReflector) ? $name->getName() : $name;
252286

253-
return $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name);
287+
return $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getBelongsToMany($name) ?? $this->getHasMany($name);
254288
}
255289

256290
/**
@@ -343,7 +377,7 @@ public function getSelectFields(): ImmutableArray
343377
foreach ($this->reflector->getPublicProperties() as $property) {
344378
$relation = $this->getRelation($property->getName());
345379

346-
if ($relation instanceof HasMany || $relation instanceof HasOne) {
380+
if ($relation instanceof HasMany || $relation instanceof HasOne || $relation instanceof BelongsToMany) {
347381
continue;
348382
}
349383

0 commit comments

Comments
 (0)