Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
444aa97
test(database): add tests for HasMany query() method
laylatichy Apr 4, 2026
b1f2991
feat(database): add query() method for HasMany relations on models
laylatichy Apr 4, 2026
f8df18c
test(database): add tests for HasManyThrough query() method
laylatichy Apr 4, 2026
5fc63ba
feat(database): add HasManyThrough support to query() method with nam…
laylatichy Apr 4, 2026
c31e696
test(database): add BelongsToMany query() tests and edge cases (expli…
laylatichy Apr 4, 2026
dd60266
feat(database): add BelongsToMany support and unsaved model guard to …
laylatichy Apr 4, 2026
49e7342
refactor(database): use getRelation() instead of separate getHasMany/…
laylatichy Apr 4, 2026
b0dea66
refactor(database): use hasPrimaryKey() and getPrimaryKeyValue() inst…
laylatichy Apr 4, 2026
1c772e1
refactor(database): extract query() builders into private methods wit…
laylatichy Apr 4, 2026
505bb41
refactor(database): pull getRelation() call out of match expression
laylatichy Apr 4, 2026
ded4caa
refactor(database): move query() to Relation interface with QueryScop…
laylatichy Apr 4, 2026
cd39538
test(database): add count, update, and delete tests for query() acros…
laylatichy Apr 4, 2026
f48f1bf
test(database): add update and delete tests for HasManyThrough and Be…
laylatichy Apr 4, 2026
91e10fa
fix(database): add missing named arguments in QueryBuilder scope calls
laylatichy Apr 4, 2026
2d56a38
refactor(database): add WhereFieldScope for HasMany, keep WhereRawSco…
laylatichy Apr 4, 2026
78560aa
fix(database): add missing named argument for scope() calls in relati…
laylatichy Apr 4, 2026
481acef
fix(database): add ON DELETE CASCADE to books_tags pivot migration fo…
laylatichy Apr 4, 2026
0369ac6
docs(database): add documentation for query() method on relation prop…
laylatichy Apr 4, 2026
62f8bb2
docs(database): clarify difference between global query() and instanc…
laylatichy Apr 4, 2026
1d1d71f
docs(database): clarify query() comes from IsDatabaseModel trait, not…
laylatichy Apr 4, 2026
66c298f
test(database): add BelongsTo select and count tests for query()
laylatichy Apr 4, 2026
ba3a40e
feat(database): implement BelongsTo query() with subquery scope
laylatichy Apr 4, 2026
6605dd1
test(database): add HasOne select, count, update, delete tests for qu…
laylatichy Apr 4, 2026
c8ec95e
feat(database): implement HasOne query() with WhereFieldScope
laylatichy Apr 4, 2026
2ea51ce
test(database): add HasOneThrough select, count, update, delete tests…
laylatichy Apr 4, 2026
d25a3b0
feat(database): implement HasOneThrough query() with subquery scope
laylatichy Apr 4, 2026
ef2bb9e
docs(database): update query() docs to reflect all relation types now…
laylatichy Apr 4, 2026
09891be
test(database): add whereHas and whereDoesntHave chaining tests for q…
laylatichy Apr 4, 2026
34aa597
refactor(database): use resolvePivotTable() in BelongsToMany::query()…
laylatichy Apr 4, 2026
50b7a48
refactor(database): replace generic exceptions with custom PrimaryKey…
laylatichy Apr 9, 2026
80bc564
fix(upgrade): run rector tests in separate processes to prevent OOM a…
laylatichy Apr 9, 2026
47da414
Merge branch '3.x' into feat-add-option-to-get-underlaying-query-for-…
brendt Apr 22, 2026
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
37 changes: 37 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,43 @@ query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execu
query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute();
```

### Querying relation properties

While the global `query(Model::class)` function creates a query builder for any model, models using the `IsDatabaseModel` trait also have a `query()` method that returns a query builder scoped to a specific relation. The returned `QueryBuilder` is pre-filtered to only include records belonging to that model:

```php
// Select with constraints
$books = $author->query('books')->select()->whereField(field: 'title', value: 'Timeline Taxi')->all();
$books = $author->query('books')->select()->limit(limit: 5)->all();

// Count related records
$count = $author->query('books')->count()->execute();

// Update scoped to relation
$author->query('books')->update(title: 'Updated')->execute();

// Delete scoped to relation
$author->query('books')->delete()->execute();
```

The `query()` method works with all relation types:

```php
// HasMany / HasOne — simple FK on related table
$author->query('books')->select()->all();
$book->query('isbn')->select()->first();

// BelongsTo — subquery through owner's FK
$book->query('author')->select()->first();

// HasManyThrough / HasOneThrough — subquery through intermediate table
$tag->query('reviewers')->select()->all();
$tag->query('topReviewer')->select()->first();

// BelongsToMany — subquery through pivot table
$tag->query('books')->select()->all();
```

## Migrations

When persisting objects to the database, a table is required to store the data. A migration is a file that instructs the framework how to manage the database schema.
Expand Down
30 changes: 30 additions & 0 deletions packages/database/src/BelongsTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

use Attribute;
use Tempest\Database\Builder\ModelInspector;
use Tempest\Database\Builder\QueryBuilders\QueryBuilder;
use Tempest\Database\Builder\QueryBuilders\WhereRawScope;
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
use Tempest\Database\QueryStatements\FieldStatement;
use Tempest\Database\QueryStatements\JoinStatement;
use Tempest\Database\QueryStatements\WhereExistsStatement;
use Tempest\Reflection\PropertyReflector;
use Tempest\Support\Arr\ImmutableArray;
use UnitEnum;

use function Tempest\Support\str;

Expand Down Expand Up @@ -191,4 +194,31 @@ private function getOwnerJoin(ModelInspector $ownerModel): string
$this->getOwnerFieldName(),
);
}

public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder
{
$relatedClassName = $this->property->getType()->getName();
$relatedModel = inspect(model: $this->property->getType()->asClass());
$ownerModel = inspect(model: $this->property->getClass());
$relatedTable = $relatedModel->getTableName();
$relatedPK = $relatedModel->getPrimaryKey();
$ownerTable = $ownerModel->getTableName();
$ownerPK = $ownerModel->getPrimaryKey();
$fk = $this->getOwnerFieldName();

return query(model: $relatedClassName)
->onDatabase(databaseTag: $onDatabase)
->scope(scope: new WhereRawScope(
statement: sprintf(
'%s.%s = (SELECT %s FROM %s WHERE %s.%s = ?)',
$relatedTable,
$relatedPK,
$fk,
$ownerTable,
$ownerTable,
$ownerPK,
),
binding: $primaryKey,
));
}
}
32 changes: 32 additions & 0 deletions packages/database/src/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

use Attribute;
use Tempest\Database\Builder\ModelInspector;
use Tempest\Database\Builder\QueryBuilders\QueryBuilder;
use Tempest\Database\Builder\QueryBuilders\WhereRawScope;
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
use Tempest\Database\QueryStatements\FieldStatement;
use Tempest\Database\QueryStatements\JoinStatement;
use Tempest\Database\QueryStatements\WhereExistsStatement;
use Tempest\Reflection\PropertyReflector;
use Tempest\Support\Arr\ImmutableArray;
use UnitEnum;

use function Tempest\Support\arr;
use function Tempest\Support\str;
Expand Down Expand Up @@ -394,4 +397,33 @@ public function getExistsStatement(): WhereExistsStatement
),
);
}

public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder
{
$ownerModel = inspect(model: $this->property->getClass());
$targetModel = inspect(model: $this->property->getIterableType()->asClass());
$relatedClassName = $this->property->getIterableType()->getName();
$ownerTable = $ownerModel->getTableName();
$ownerPK = $ownerModel->getPrimaryKey();
$targetTable = $targetModel->getTableName();
$targetPK = $targetModel->getPrimaryKey();

$pivotTable = $this->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel);
$ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK;
$targetFK = $this->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK;

return query(model: $relatedClassName)
->onDatabase(databaseTag: $onDatabase)
->scope(scope: new WhereRawScope(
statement: sprintf(
'%s.%s IN (SELECT %s FROM %s WHERE %s = ?)',
$targetTable,
$targetPK,
$targetFK,
$pivotTable,
$ownerFK,
),
binding: $primaryKey,
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ trait HasWhereQueryBuilderMethods
use HasConvenientWhereMethods;
use HasWhereRelationMethods;

/**
* @param QueryScope[] $scopes
*/
public function applyScopes(array $scopes): self
{
foreach ($scopes as $scope) {
$scope->apply(builder: $this);
}

return $this;
}

protected function appendWhere(WhereStatement|WhereGroupStatement|WhereExistsStatement $where): void
{
$this->wheres->offsetSet(null, $where);
Expand Down
31 changes: 27 additions & 4 deletions packages/database/src/Builder/QueryBuilders/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,26 @@ final class QueryBuilder
{
use OnDatabase;

/** @var QueryScope[] */
private array $scopes = [];

/** @param class-string<TModel>|TModel|string $model */
public function __construct(
private readonly string|object $model,
) {}

/**
* Adds a scope that will be applied to any query builder created from this instance.
*
* @return self<TModel>
*/
public function scope(QueryScope $scope): self
{
$this->scopes[] = $scope;

return $this;
}

/**
* Creates a `SELECT` query builder for retrieving records from the database.
*
Expand All @@ -41,7 +56,9 @@ public function select(string ...$columns): SelectQueryBuilder
return new SelectQueryBuilder(
model: $this->model,
fields: $columns !== [] ? arr($columns)->unique() : null,
)->onDatabase($this->onDatabase);
)
->onDatabase(databaseTag: $this->onDatabase)
->applyScopes(scopes: $this->scopes);
}

/**
Expand Down Expand Up @@ -88,7 +105,9 @@ public function update(mixed ...$values): UpdateQueryBuilder
model: $this->model,
values: $values,
serializerFactory: get(SerializerFactory::class),
)->onDatabase($this->onDatabase);
)
->onDatabase(databaseTag: $this->onDatabase)
->applyScopes(scopes: $this->scopes);
}

/**
Expand All @@ -106,7 +125,9 @@ public function update(mixed ...$values): UpdateQueryBuilder
*/
public function delete(): DeleteQueryBuilder
{
return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase);
return new DeleteQueryBuilder(model: $this->model)
->onDatabase(databaseTag: $this->onDatabase)
->applyScopes(scopes: $this->scopes);
}

/**
Expand All @@ -124,7 +145,9 @@ public function count(?string $column = null): CountQueryBuilder
return new CountQueryBuilder(
model: $this->model,
column: $column,
)->onDatabase($this->onDatabase);
)
->onDatabase(databaseTag: $this->onDatabase)
->applyScopes(scopes: $this->scopes);
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/database/src/Builder/QueryBuilders/QueryScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Builder\QueryBuilders;

interface QueryScope
{
public function apply(SupportsWhereStatements $builder): void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,11 @@ public function whereField(string $field, mixed $value, string|WhereOperator $op
* @return self<TModel>
*/
public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self;

/**
* Adds a raw WHERE condition to the query.
*
* @return self<TModel>
*/
public function whereRaw(string $statement, mixed ...$bindings): self;
}
18 changes: 18 additions & 0 deletions packages/database/src/Builder/QueryBuilders/WhereFieldScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Builder\QueryBuilders;

final readonly class WhereFieldScope implements QueryScope
{
public function __construct(
private string $field,
private mixed $value,
) {}

public function apply(SupportsWhereStatements $builder): void
{
$builder->whereField(field: $this->field, value: $this->value);
}
}
18 changes: 18 additions & 0 deletions packages/database/src/Builder/QueryBuilders/WhereRawScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Builder\QueryBuilders;

final readonly class WhereRawScope implements QueryScope
{
public function __construct(
private string $statement,
private mixed $binding,
) {}

public function apply(SupportsWhereStatements $builder): void
{
$builder->whereRaw($this->statement, $this->binding);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Exceptions;

use Exception;

final class PrimaryKeyWasNotInitialized extends Exception
{
public function __construct(
public readonly string $model,
) {
parent::__construct("Cannot query relations on `{$model}` without a primary key value.");
}
}
17 changes: 17 additions & 0 deletions packages/database/src/Exceptions/PropertyWasNotARelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Exceptions;

use Exception;

final class PropertyWasNotARelation extends Exception
{
public function __construct(
public readonly string $property,
public readonly string $model,
) {
parent::__construct("Property `{$property}` is not a relation on `{$model}`.");
}
}
16 changes: 16 additions & 0 deletions packages/database/src/HasMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

use Attribute;
use Tempest\Database\Builder\ModelInspector;
use Tempest\Database\Builder\QueryBuilders\QueryBuilder;
use Tempest\Database\Builder\QueryBuilders\WhereFieldScope;
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
use Tempest\Database\QueryStatements\FieldStatement;
use Tempest\Database\QueryStatements\JoinStatement;
use Tempest\Database\QueryStatements\WhereExistsStatement;
use Tempest\Reflection\PropertyReflector;
use Tempest\Support\Arr\ImmutableArray;
use UnitEnum;

use function Tempest\Support\str;

Expand Down Expand Up @@ -178,6 +181,19 @@ public function getExistsStatement(): WhereExistsStatement
);
}

public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder
{
$relatedClassName = $this->property->getIterableType()->getName();
$parentModel = inspect(model: $this->property->getClass());
$parentTable = $parentModel->getTableName();
$parentPK = $parentModel->getPrimaryKey();
$fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord() . '_' . $parentPK;

return query(model: $relatedClassName)
->onDatabase(databaseTag: $onDatabase)
->scope(scope: new WhereFieldScope(field: $fk, value: $primaryKey));
}

private function isSelfReferencing(): bool
{
$relationModel = inspect($this->property->getIterableType()->asClass());
Expand Down
Loading
Loading