Skip to content

Commit 6398523

Browse files
laylatichybrendt
andauthored
feat(database): add option to get underlying query for property relation (#2102)
Co-authored-by: Brent Roose <brent.roose@gmail.com>
1 parent 657e1da commit 6398523

23 files changed

Lines changed: 1162 additions & 6 deletions

docs/1-essentials/03-database.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,43 @@ query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execu
757757
query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute();
758758
```
759759

760+
### Querying relation properties
761+
762+
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:
763+
764+
```php
765+
// Select with constraints
766+
$books = $author->query('books')->select()->whereField(field: 'title', value: 'Timeline Taxi')->all();
767+
$books = $author->query('books')->select()->limit(limit: 5)->all();
768+
769+
// Count related records
770+
$count = $author->query('books')->count()->execute();
771+
772+
// Update scoped to relation
773+
$author->query('books')->update(title: 'Updated')->execute();
774+
775+
// Delete scoped to relation
776+
$author->query('books')->delete()->execute();
777+
```
778+
779+
The `query()` method works with all relation types:
780+
781+
```php
782+
// HasMany / HasOne — simple FK on related table
783+
$author->query('books')->select()->all();
784+
$book->query('isbn')->select()->first();
785+
786+
// BelongsTo — subquery through owner's FK
787+
$book->query('author')->select()->first();
788+
789+
// HasManyThrough / HasOneThrough — subquery through intermediate table
790+
$tag->query('reviewers')->select()->all();
791+
$tag->query('topReviewer')->select()->first();
792+
793+
// BelongsToMany — subquery through pivot table
794+
$tag->query('books')->select()->all();
795+
```
796+
760797
## Migrations
761798

762799
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.

packages/database/src/BelongsTo.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77
use Attribute;
88
use Tempest\Database\Builder\ModelInspector;
9+
use Tempest\Database\Builder\QueryBuilders\QueryBuilder;
10+
use Tempest\Database\Builder\QueryBuilders\WhereRawScope;
911
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
1012
use Tempest\Database\QueryStatements\FieldStatement;
1113
use Tempest\Database\QueryStatements\JoinStatement;
1214
use Tempest\Database\QueryStatements\WhereExistsStatement;
1315
use Tempest\Reflection\PropertyReflector;
1416
use Tempest\Support\Arr\ImmutableArray;
17+
use UnitEnum;
1518

1619
use function Tempest\Support\str;
1720

@@ -191,4 +194,31 @@ private function getOwnerJoin(ModelInspector $ownerModel): string
191194
$this->getOwnerFieldName(),
192195
);
193196
}
197+
198+
public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder
199+
{
200+
$relatedClassName = $this->property->getType()->getName();
201+
$relatedModel = inspect(model: $this->property->getType()->asClass());
202+
$ownerModel = inspect(model: $this->property->getClass());
203+
$relatedTable = $relatedModel->getTableName();
204+
$relatedPK = $relatedModel->getPrimaryKey();
205+
$ownerTable = $ownerModel->getTableName();
206+
$ownerPK = $ownerModel->getPrimaryKey();
207+
$fk = $this->getOwnerFieldName();
208+
209+
return query(model: $relatedClassName)
210+
->onDatabase(databaseTag: $onDatabase)
211+
->scope(scope: new WhereRawScope(
212+
statement: sprintf(
213+
'%s.%s = (SELECT %s FROM %s WHERE %s.%s = ?)',
214+
$relatedTable,
215+
$relatedPK,
216+
$fk,
217+
$ownerTable,
218+
$ownerTable,
219+
$ownerPK,
220+
),
221+
binding: $primaryKey,
222+
));
223+
}
194224
}

packages/database/src/BelongsToMany.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77
use Attribute;
88
use Tempest\Database\Builder\ModelInspector;
9+
use Tempest\Database\Builder\QueryBuilders\QueryBuilder;
10+
use Tempest\Database\Builder\QueryBuilders\WhereRawScope;
911
use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn;
1012
use Tempest\Database\QueryStatements\FieldStatement;
1113
use Tempest\Database\QueryStatements\JoinStatement;
1214
use Tempest\Database\QueryStatements\WhereExistsStatement;
1315
use Tempest\Reflection\PropertyReflector;
1416
use Tempest\Support\Arr\ImmutableArray;
17+
use UnitEnum;
1518

1619
use function Tempest\Support\arr;
1720
use function Tempest\Support\str;
@@ -394,4 +397,33 @@ public function getExistsStatement(): WhereExistsStatement
394397
),
395398
);
396399
}
400+
401+
public function query(PrimaryKey $primaryKey, null|string|UnitEnum $onDatabase = null): QueryBuilder
402+
{
403+
$ownerModel = inspect(model: $this->property->getClass());
404+
$targetModel = inspect(model: $this->property->getIterableType()->asClass());
405+
$relatedClassName = $this->property->getIterableType()->getName();
406+
$ownerTable = $ownerModel->getTableName();
407+
$ownerPK = $ownerModel->getPrimaryKey();
408+
$targetTable = $targetModel->getTableName();
409+
$targetPK = $targetModel->getPrimaryKey();
410+
411+
$pivotTable = $this->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel);
412+
$ownerFK = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord() . '_' . $ownerPK;
413+
$targetFK = $this->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord() . '_' . $targetPK;
414+
415+
return query(model: $relatedClassName)
416+
->onDatabase(databaseTag: $onDatabase)
417+
->scope(scope: new WhereRawScope(
418+
statement: sprintf(
419+
'%s.%s IN (SELECT %s FROM %s WHERE %s = ?)',
420+
$targetTable,
421+
$targetPK,
422+
$targetFK,
423+
$pivotTable,
424+
$ownerFK,
425+
),
426+
binding: $primaryKey,
427+
));
428+
}
397429
}

packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ trait HasWhereQueryBuilderMethods
1515
use HasConvenientWhereMethods;
1616
use HasWhereRelationMethods;
1717

18+
/**
19+
* @param QueryScope[] $scopes
20+
*/
21+
public function applyScopes(array $scopes): self
22+
{
23+
foreach ($scopes as $scope) {
24+
$scope->apply(builder: $this);
25+
}
26+
27+
return $this;
28+
}
29+
1830
protected function appendWhere(WhereStatement|WhereGroupStatement|WhereExistsStatement $where): void
1931
{
2032
$this->wheres->offsetSet(null, $where);

packages/database/src/Builder/QueryBuilders/QueryBuilder.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,26 @@ final class QueryBuilder
1919
{
2020
use OnDatabase;
2121

22+
/** @var QueryScope[] */
23+
private array $scopes = [];
24+
2225
/** @param class-string<TModel>|TModel|string $model */
2326
public function __construct(
2427
private readonly string|object $model,
2528
) {}
2629

30+
/**
31+
* Adds a scope that will be applied to any query builder created from this instance.
32+
*
33+
* @return self<TModel>
34+
*/
35+
public function scope(QueryScope $scope): self
36+
{
37+
$this->scopes[] = $scope;
38+
39+
return $this;
40+
}
41+
2742
/**
2843
* Creates a `SELECT` query builder for retrieving records from the database.
2944
*
@@ -41,7 +56,9 @@ public function select(string ...$columns): SelectQueryBuilder
4156
return new SelectQueryBuilder(
4257
model: $this->model,
4358
fields: $columns !== [] ? arr($columns)->unique() : null,
44-
)->onDatabase($this->onDatabase);
59+
)
60+
->onDatabase(databaseTag: $this->onDatabase)
61+
->applyScopes(scopes: $this->scopes);
4562
}
4663

4764
/**
@@ -88,7 +105,9 @@ public function update(mixed ...$values): UpdateQueryBuilder
88105
model: $this->model,
89106
values: $values,
90107
serializerFactory: get(SerializerFactory::class),
91-
)->onDatabase($this->onDatabase);
108+
)
109+
->onDatabase(databaseTag: $this->onDatabase)
110+
->applyScopes(scopes: $this->scopes);
92111
}
93112

94113
/**
@@ -106,7 +125,9 @@ public function update(mixed ...$values): UpdateQueryBuilder
106125
*/
107126
public function delete(): DeleteQueryBuilder
108127
{
109-
return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase);
128+
return new DeleteQueryBuilder(model: $this->model)
129+
->onDatabase(databaseTag: $this->onDatabase)
130+
->applyScopes(scopes: $this->scopes);
110131
}
111132

112133
/**
@@ -124,7 +145,9 @@ public function count(?string $column = null): CountQueryBuilder
124145
return new CountQueryBuilder(
125146
model: $this->model,
126147
column: $column,
127-
)->onDatabase($this->onDatabase);
148+
)
149+
->onDatabase(databaseTag: $this->onDatabase)
150+
->applyScopes(scopes: $this->scopes);
128151
}
129152

130153
/**
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Builder\QueryBuilders;
6+
7+
interface QueryScope
8+
{
9+
public function apply(SupportsWhereStatements $builder): void;
10+
}

packages/database/src/Builder/QueryBuilders/SupportsWhereStatements.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,11 @@ public function whereField(string $field, mixed $value, string|WhereOperator $op
3232
* @return self<TModel>
3333
*/
3434
public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self;
35+
36+
/**
37+
* Adds a raw WHERE condition to the query.
38+
*
39+
* @return self<TModel>
40+
*/
41+
public function whereRaw(string $statement, mixed ...$bindings): self;
3542
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Builder\QueryBuilders;
6+
7+
final readonly class WhereFieldScope implements QueryScope
8+
{
9+
public function __construct(
10+
private string $field,
11+
private mixed $value,
12+
) {}
13+
14+
public function apply(SupportsWhereStatements $builder): void
15+
{
16+
$builder->whereField(field: $this->field, value: $this->value);
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Builder\QueryBuilders;
6+
7+
final readonly class WhereRawScope implements QueryScope
8+
{
9+
public function __construct(
10+
private string $statement,
11+
private mixed $binding,
12+
) {}
13+
14+
public function apply(SupportsWhereStatements $builder): void
15+
{
16+
$builder->whereRaw($this->statement, $this->binding);
17+
}
18+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Exceptions;
6+
7+
use Exception;
8+
9+
final class PrimaryKeyWasNotInitialized extends Exception
10+
{
11+
public function __construct(
12+
public readonly string $model,
13+
) {
14+
parent::__construct("Cannot query relations on `{$model}` without a primary key value.");
15+
}
16+
}

0 commit comments

Comments
 (0)