Skip to content

Commit 321fecf

Browse files
authored
feat(database): add support for hasonethrough relation (#2050)
1 parent 7d2a433 commit 321fecf

5 files changed

Lines changed: 506 additions & 4 deletions

File tree

docs/1-essentials/03-database.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,49 @@ final class Book
250250
}
251251
```
252252

253+
### Has one through
254+
255+
The {b`#[Tempest\Database\HasOneThrough]`} attribute defines a one-to-one relationship that traverses an intermediate model. This lets you access a distant relation directly, resolved in a single SQL query with two JOINs.
256+
257+
```php
258+
use Tempest\Database\HasOne;
259+
use Tempest\Database\HasOneThrough;
260+
261+
final class Author
262+
{
263+
#[HasOne]
264+
public ?Profile $profile = null;
265+
266+
#[HasOneThrough(Profile::class)]
267+
public ?Address $address = null;
268+
}
269+
```
270+
271+
The `through` parameter specifies the intermediate model class. The target model is inferred from the property type. This generates SQL like:
272+
273+
```sql
274+
LEFT JOIN profiles ON profiles.author_id = authors.id
275+
LEFT JOIN addresses ON addresses.profile_id = profiles.id
276+
```
277+
278+
When conventions don't match, optional parameters can override the join fields:
279+
280+
```php
281+
#[HasOneThrough(
282+
through: Profile::class,
283+
ownerJoin: 'custom_author_fk',
284+
relationJoin: 'uuid',
285+
throughOwnerJoin: 'custom_profile_fk',
286+
throughRelationJoin: 'uuid',
287+
)]
288+
public ?Address $address = null;
289+
```
290+
291+
- `ownerJoin`: FK on the intermediate table pointing to the owner
292+
- `relationJoin`: PK on the owner table
293+
- `throughOwnerJoin`: FK on the target table pointing to the intermediate
294+
- `throughRelationJoin`: PK on the intermediate table
295+
253296
### Using UUIDs as primary keys
254297

255298
By default, Tempest uses auto-incrementing integers as primary keys. UUIDs can be used as primary keys instead by annotating the {b`Tempest\Database\PrimaryKey`} property with the {b`#[Tempest\Database\Uuid]`} attribute. Tempest automatically generates a UUID v7 when a new model is created:

packages/database/src/Builder/ModelInspector.php

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Tempest\Database\Eager;
99
use Tempest\Database\HasMany;
1010
use Tempest\Database\HasOne;
11+
use Tempest\Database\HasOneThrough;
1112
use Tempest\Database\PrimaryKey;
1213
use Tempest\Database\Relation;
1314
use Tempest\Database\Table;
@@ -55,6 +56,7 @@ public static function forModel(object|string $model): self
5556
$model instanceof HasMany => $model->property->getIterableType()->getName(),
5657
$model instanceof BelongsTo => $model->property->getType()->getName(),
5758
$model instanceof HasOne => $model->property->getType()->getName(),
59+
$model instanceof HasOneThrough => $model->property->getType()->getName(),
5860
$model instanceof ClassReflector => $model->getName(),
5961
default => null,
6062
};
@@ -72,7 +74,7 @@ public function __construct(
7274
if ($model instanceof HasMany) {
7375
$model = $model->property->getIterableType()->asClass();
7476
$this->reflector = $model;
75-
} elseif ($model instanceof BelongsTo || $model instanceof HasOne) {
77+
} elseif ($model instanceof BelongsTo || $model instanceof HasOne || $model instanceof HasOneThrough) {
7678
$model = $model->property->getType()->asClass();
7779
$this->reflector = $model;
7880
} elseif ($model instanceof ClassReflector) {
@@ -158,6 +160,10 @@ public function getPropertyValues(): array
158160
continue;
159161
}
160162

163+
if ($this->getHasOneThrough($property->getName()) instanceof HasOneThrough) {
164+
continue;
165+
}
166+
161167
$name = $property->getName();
162168

163169
$values[$name] = $property->getValue($this->instance);
@@ -212,6 +218,10 @@ public function getBelongsTo(string $name): ?BelongsTo
212218
return null;
213219
}
214220

221+
if ($property->hasAttribute(HasOneThrough::class)) {
222+
return null;
223+
}
224+
215225
$belongsTo = new BelongsTo();
216226
$belongsTo->property = $property;
217227

@@ -282,21 +292,55 @@ public function getHasMany(string $name): ?HasMany
282292
});
283293
}
284294

295+
public function getHasOneThrough(string $name): ?HasOneThrough
296+
{
297+
return $this->memoize('getHasOneThrough' . $name, function () use ($name) {
298+
if (! $this->isObjectModel()) {
299+
return null;
300+
}
301+
302+
$name = str($name)->camel();
303+
304+
$singularizedName = $name->singularizeLastWord();
305+
306+
if (! $singularizedName->equals($name)) {
307+
return $this->getHasOneThrough($singularizedName);
308+
}
309+
310+
if (! $this->reflector->hasProperty($name)) {
311+
return null;
312+
}
313+
314+
$property = $this->reflector->getProperty($name);
315+
316+
if (($hasOneThrough = $property->getAttribute(HasOneThrough::class)) instanceof HasOneThrough) {
317+
return $hasOneThrough;
318+
}
319+
320+
return null;
321+
});
322+
}
323+
285324
public function isRelation(string|PropertyReflector $name): bool
286325
{
287326
$name = $name instanceof PropertyReflector ? $name->getName() : $name;
288327

289328
return $this->memoize(
290329
'isRelation' . $name,
291-
fn () => $this->getBelongsTo($name) instanceof BelongsTo || $this->getHasOne($name) instanceof HasOne || $this->getHasMany($name) instanceof HasMany,
330+
fn () => (
331+
$this->getBelongsTo($name) instanceof BelongsTo
332+
|| $this->getHasOne($name) instanceof HasOne
333+
|| $this->getHasMany($name) instanceof HasMany
334+
|| $this->getHasOneThrough($name) instanceof HasOneThrough
335+
),
292336
);
293337
}
294338

295339
public function getRelation(string|PropertyReflector $name): ?Relation
296340
{
297341
$name = $name instanceof PropertyReflector ? $name->getName() : $name;
298342

299-
return $this->memoize('getRelation' . $name, fn () => $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name));
343+
return $this->memoize('getRelation' . $name, fn () => $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name) ?? $this->getHasOneThrough($name));
300344
}
301345

302346
/**
@@ -396,6 +440,10 @@ public function getSelectFields(): ImmutableArray
396440
continue;
397441
}
398442

443+
if ($relation instanceof HasOneThrough) {
444+
continue;
445+
}
446+
399447
if ($property->isVirtual()) {
400448
continue;
401449
}

0 commit comments

Comments
 (0)