Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,49 @@ final class Book
}
```

### Has one through

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.

```php
use Tempest\Database\HasOne;
use Tempest\Database\HasOneThrough;

final class Author
{
#[HasOne]
public ?Profile $profile = null;

#[HasOneThrough(Profile::class)]
public ?Address $address = null;
}
```

The `through` parameter specifies the intermediate model class. The target model is inferred from the property type. This generates SQL like:

```sql
LEFT JOIN profiles ON profiles.author_id = authors.id
LEFT JOIN addresses ON addresses.profile_id = profiles.id
```

When conventions don't match, optional parameters can override the join fields:

```php
#[HasOneThrough(
through: Profile::class,
ownerJoin: 'custom_author_fk',
relationJoin: 'uuid',
throughOwnerJoin: 'custom_profile_fk',
throughRelationJoin: 'uuid',
)]
public ?Address $address = null;
```

- `ownerJoin`: FK on the intermediate table pointing to the owner
- `relationJoin`: PK on the owner table
- `throughOwnerJoin`: FK on the target table pointing to the intermediate
- `throughRelationJoin`: PK on the intermediate table

### Using UUIDs as primary keys

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:
Expand Down
54 changes: 51 additions & 3 deletions packages/database/src/Builder/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Tempest\Database\Eager;
use Tempest\Database\HasMany;
use Tempest\Database\HasOne;
use Tempest\Database\HasOneThrough;
use Tempest\Database\PrimaryKey;
use Tempest\Database\Relation;
use Tempest\Database\Table;
Expand Down Expand Up @@ -55,6 +56,7 @@ public static function forModel(object|string $model): self
$model instanceof HasMany => $model->property->getIterableType()->getName(),
$model instanceof BelongsTo => $model->property->getType()->getName(),
$model instanceof HasOne => $model->property->getType()->getName(),
$model instanceof HasOneThrough => $model->property->getType()->getName(),
$model instanceof ClassReflector => $model->getName(),
default => null,
};
Expand All @@ -72,7 +74,7 @@ public function __construct(
if ($model instanceof HasMany) {
$model = $model->property->getIterableType()->asClass();
$this->reflector = $model;
} elseif ($model instanceof BelongsTo || $model instanceof HasOne) {
} elseif ($model instanceof BelongsTo || $model instanceof HasOne || $model instanceof HasOneThrough) {
$model = $model->property->getType()->asClass();
$this->reflector = $model;
} elseif ($model instanceof ClassReflector) {
Expand Down Expand Up @@ -158,6 +160,10 @@ public function getPropertyValues(): array
continue;
}

if ($this->getHasOneThrough($property->getName()) instanceof HasOneThrough) {
continue;
}

$name = $property->getName();

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

if ($property->hasAttribute(HasOneThrough::class)) {
return null;
}

$belongsTo = new BelongsTo();
$belongsTo->property = $property;

Expand Down Expand Up @@ -282,21 +292,55 @@ public function getHasMany(string $name): ?HasMany
});
}

public function getHasOneThrough(string $name): ?HasOneThrough
{
return $this->memoize('getHasOneThrough' . $name, function () use ($name) {
if (! $this->isObjectModel()) {
return null;
}

$name = str($name)->camel();

$singularizedName = $name->singularizeLastWord();

if (! $singularizedName->equals($name)) {
return $this->getHasOneThrough($singularizedName);
}

if (! $this->reflector->hasProperty($name)) {
return null;
}

$property = $this->reflector->getProperty($name);

if (($hasOneThrough = $property->getAttribute(HasOneThrough::class)) instanceof HasOneThrough) {
return $hasOneThrough;
}

return null;
});
}

public function isRelation(string|PropertyReflector $name): bool
{
$name = $name instanceof PropertyReflector ? $name->getName() : $name;

return $this->memoize(
'isRelation' . $name,
fn () => $this->getBelongsTo($name) instanceof BelongsTo || $this->getHasOne($name) instanceof HasOne || $this->getHasMany($name) instanceof HasMany,
fn () => (
$this->getBelongsTo($name) instanceof BelongsTo
|| $this->getHasOne($name) instanceof HasOne
|| $this->getHasMany($name) instanceof HasMany
|| $this->getHasOneThrough($name) instanceof HasOneThrough
),
);
}

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

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

/**
Expand Down Expand Up @@ -396,6 +440,10 @@ public function getSelectFields(): ImmutableArray
continue;
}

if ($relation instanceof HasOneThrough) {
continue;
}

if ($property->isVirtual()) {
continue;
}
Expand Down
Loading
Loading