Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
aaa3bbc
test(database): add tests for HasMany query() method
laylatichy Apr 4, 2026
e16300a
feat(database): add query() method for HasMany relations on models
laylatichy Apr 4, 2026
1670826
test(database): add tests for HasManyThrough query() method
laylatichy Apr 4, 2026
082668b
feat(database): add HasManyThrough support to query() method with nam…
laylatichy Apr 4, 2026
91cfb4c
test(database): add BelongsToMany query() tests and edge cases (expli…
laylatichy Apr 4, 2026
76c6bdd
feat(database): add BelongsToMany support and unsaved model guard to …
laylatichy Apr 4, 2026
c8b90b8
refactor(database): use getRelation() instead of separate getHasMany/…
laylatichy Apr 4, 2026
90a898c
refactor(database): use hasPrimaryKey() and getPrimaryKeyValue() inst…
laylatichy Apr 4, 2026
de2d2e2
refactor(database): extract query() builders into private methods wit…
laylatichy Apr 4, 2026
67e381d
refactor(database): pull getRelation() call out of match expression
laylatichy Apr 4, 2026
3221075
refactor(database): move query() to Relation interface with QueryScop…
laylatichy Apr 4, 2026
9060736
test(database): add count, update, and delete tests for query() acros…
laylatichy Apr 4, 2026
c92e9ed
test(database): add update and delete tests for HasManyThrough and Be…
laylatichy Apr 4, 2026
d91df17
fix(database): add missing named arguments in QueryBuilder scope calls
laylatichy Apr 4, 2026
cc07f5d
refactor(database): add WhereFieldScope for HasMany, keep WhereRawSco…
laylatichy Apr 4, 2026
4061af0
fix(database): add missing named argument for scope() calls in relati…
laylatichy Apr 4, 2026
da368e1
fix(database): add ON DELETE CASCADE to books_tags pivot migration fo…
laylatichy Apr 4, 2026
329f1b6
docs(database): add documentation for query() method on relation prop…
laylatichy Apr 4, 2026
515073c
docs(database): clarify difference between global query() and instanc…
laylatichy Apr 4, 2026
2815b4c
docs(database): clarify query() comes from IsDatabaseModel trait, not…
laylatichy Apr 4, 2026
742192c
test(database): add BelongsTo select and count tests for query()
laylatichy Apr 4, 2026
b62dd25
feat(database): implement BelongsTo query() with subquery scope
laylatichy Apr 4, 2026
bc8d8fe
test(database): add HasOne select, count, update, delete tests for qu…
laylatichy Apr 4, 2026
be9bbb0
feat(database): implement HasOne query() with WhereFieldScope
laylatichy Apr 4, 2026
c121684
test(database): add HasOneThrough select, count, update, delete tests…
laylatichy Apr 4, 2026
f1442b8
feat(database): implement HasOneThrough query() with subquery scope
laylatichy Apr 4, 2026
8aadfb1
docs(database): update query() docs to reflect all relation types now…
laylatichy Apr 4, 2026
d6d4ec2
test(database): add whereHas and whereDoesntHave chaining tests for q…
laylatichy Apr 4, 2026
5684a1c
refactor(database): use resolvePivotTable() in BelongsToMany::query()…
laylatichy Apr 4, 2026
1007539
refactor(database): replace generic exceptions with custom PrimaryKey…
laylatichy Apr 9, 2026
71ee424
fix(upgrade): run rector tests in separate processes to prevent OOM a…
laylatichy Apr 9, 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