Skip to content
Open
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
58 changes: 58 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,64 @@ $books[0]->chapters[2]->delete();

:::

### Filtering by relations

Use `whereHas` and `whereDoesntHave` to filter models based on whether related records exist:

```php
// Authors who have at least one book
$authors = Author::select()
->whereHas(relation: 'books')
->all();

// Authors who have no books
$authors = Author::select()
->whereDoesntHave(relation: 'books')
->all();
```

Add a callback to constrain the related records:

```php
// Authors who have a published book
$authors = Author::select()
->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void {
$query->whereField(field: 'published', value: true);
})
->all();
```

Use `operator` and `count` for count-based filtering:

```php
// Authors with 3 or more books
$authors = Author::select()
->whereHas(relation: 'books', operator: WhereOperator::GREATER_THAN_OR_EQUAL, count: 3)
->all();
```

Dot notation supports nested relations:

```php
// Authors who have books with chapters
$authors = Author::select()
->whereHas(relation: 'books.chapters')
->all();
```

These methods work on all query builders:

```php
// Count authors with books
$count = Author::count()->whereHas(relation: 'books')->execute();

// Delete authors without books
query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execute();

// Update authors who have books
query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute();
```

## 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
2 changes: 1 addition & 1 deletion packages/core/src/Kernel/LoadConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function find(): array
})
->sortByCallback(function (string $path1, string $path2) use ($suffixes): int {
$getPriority = fn (string $path): int => match (true) {
Str\contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR) => 0,
Str\contains($path, '/vendor/') => 0,
! Str\contains($path, root_path()) => 0,
Str\contains($path, $suffixes['testing']) => 6,
Str\contains($path, $suffixes['development']) => 5,
Expand Down
13 changes: 13 additions & 0 deletions packages/database/src/AggregateFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

enum AggregateFunction: string
{
case SUM = 'SUM';
case AVG = 'AVG';
case MAX = 'MAX';
case MIN = 'MIN';
}
19 changes: 19 additions & 0 deletions packages/database/src/BelongsTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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;

Expand Down Expand Up @@ -137,6 +138,24 @@ private function getRelationJoin(ModelInspector $relationModel, string $tableAli
return sprintf('%s.%s', $tableReference, $primaryKey);
}

public function getExistsStatement(): WhereExistsStatement
{
$relatedModel = inspect(model: $this->property->getType()->asClass());
$parentModel = inspect(model: $this->property->getClass());

$relatedTable = $relatedModel->getTableName();
$parentTable = $parentModel->getTableName();
$relatedPK = $relatedModel->getPrimaryKey();

$fk = $this->getOwnerFieldName();

return new WhereExistsStatement(
relatedTable: $relatedTable,
relatedModelName: $relatedModel->getName(),
condition: "{$relatedTable}.{$relatedPK} = {$parentTable}.{$fk}",
);
}

private function isSelfReferencing(): bool
{
$relationModel = inspect($this->property->getType()->asClass());
Expand Down
18 changes: 18 additions & 0 deletions packages/database/src/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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;

Expand Down Expand Up @@ -364,4 +365,21 @@ private function resolveRelatedRelationJoin(ModelInspector $targetModel, string
$primaryKey,
);
}

public function getExistsStatement(): WhereExistsStatement
{
$ownerModel = inspect(model: $this->property->getClass());
$targetModel = inspect(model: $this->property->getIterableType()->asClass());
$pivotTable = $this->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel);

$ownerPK = $ownerModel->getPrimaryKey();
$ownerTable = $ownerModel->getTableName();
$fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}");

return new WhereExistsStatement(
relatedTable: $pivotTable,
relatedModelName: $targetModel->getName(),
condition: "{$pivotTable}.{$fk} = {$ownerTable}.{$ownerPK}",
);
}
}
6 changes: 3 additions & 3 deletions packages/database/src/Builder/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ public function getBelongsTo(string $name): ?BelongsTo

$singularizedName = $name->singularizeLastWord();

if (! $singularizedName->equals($name)) {
if (! $singularizedName->equals($name) && $this->reflector->hasProperty($singularizedName)) {
return $this->getBelongsTo($singularizedName);
}

Expand Down Expand Up @@ -264,7 +264,7 @@ public function getHasOne(string $name): ?HasOne

$singularizedName = $name->singularizeLastWord();

if (! $singularizedName->equals($name)) {
if (! $singularizedName->equals($name) && $this->reflector->hasProperty($singularizedName)) {
return $this->getHasOne($singularizedName);
}

Expand Down Expand Up @@ -343,7 +343,7 @@ public function getHasOneThrough(string $name): ?HasOneThrough

$singularizedName = $name->singularizeLastWord();

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Closure;
use Tempest\Database\Builder\WhereOperator;
use Tempest\Database\QueryStatements\WhereExistsStatement;
use Tempest\Database\QueryStatements\WhereGroupStatement;
use Tempest\Database\QueryStatements\WhereStatement;

Expand All @@ -12,8 +13,9 @@
trait HasWhereQueryBuilderMethods
{
use HasConvenientWhereMethods;
use HasWhereRelationMethods;

protected function appendWhere(WhereStatement|WhereGroupStatement $where): void
protected function appendWhere(WhereStatement|WhereGroupStatement|WhereExistsStatement $where): void
{
$this->wheres->offsetSet(null, $where);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

namespace Tempest\Database\Builder\QueryBuilders;

use Closure;
use Tempest\Database\Builder\WhereConnector;
use Tempest\Database\Builder\WhereOperator;
use Tempest\Database\QueryStatements\WhereExistsStatement;
use Tempest\Database\QueryStatements\WhereGroupStatement;
use Tempest\Database\QueryStatements\WhereStatement;
use Tempest\Support\Arr\ImmutableArray;

use function Tempest\Support\arr;
use function Tempest\Support\str;

trait HasWhereRelationMethods
{
/**
* Adds a `WHERE EXISTS` condition for a relation.
* When `$count` is 1 and `$operator` is `>=` (defaults), uses an `EXISTS` subquery.
* Otherwise, uses a `COUNT(*)` subquery with the given operator and count.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return static<TModel>
*/
public function whereHas(
string $relation,
?Closure $callback = null,
string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL,
int $count = 1,
): self {
return $this->addHasCondition(
relation: $relation,
callback: $callback,
operator: WhereOperator::fromOperator(value: $operator),
count: $count,
connector: WhereConnector::AND,
negate: false,
);
}

/**
* Adds a `WHERE NOT EXISTS` condition for a relation.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return static<TModel>
*/
public function whereDoesntHave(
string $relation,
?Closure $callback = null,
): self {
return $this->addHasCondition(
relation: $relation,
callback: $callback,
operator: WhereOperator::GREATER_THAN_OR_EQUAL,
count: 1,
connector: WhereConnector::AND,
negate: true,
);
}

/**
* Adds an `OR WHERE EXISTS` condition for a relation.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return static<TModel>
*/
public function orWhereHas(
string $relation,
?Closure $callback = null,
string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL,
int $count = 1,
): self {
return $this->addHasCondition(
relation: $relation,
callback: $callback,
operator: WhereOperator::fromOperator(value: $operator),
count: $count,
connector: WhereConnector::OR,
negate: false,
);
}

/**
* Adds an `OR WHERE NOT EXISTS` condition for a relation.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return static<TModel>
*/
public function orWhereDoesntHave(
string $relation,
?Closure $callback = null,
): self {
return $this->addHasCondition(
relation: $relation,
callback: $callback,
operator: WhereOperator::GREATER_THAN_OR_EQUAL,
count: 1,
connector: WhereConnector::OR,
negate: true,
);
}

private function addHasCondition(
string $relation,
?Closure $callback,
WhereOperator $operator,
int $count,
WhereConnector $connector,
bool $negate,
): self {
$parts = str(string: $relation)->explode(
separator: '.',
limit: 2,
);
$relationName = (string) $parts[0];
$nestedPath = isset($parts[1])
? (string) $parts[1]
: null;

$existsStatement = $this->model
->getRelation(name: $relationName)
->getExistsStatement();

$useCount = ! $negate && ($count !== 1 || $operator !== WhereOperator::GREATER_THAN_OR_EQUAL);

/** @var ImmutableArray<WhereStatement|WhereGroupStatement|WhereExistsStatement> $innerWheres */
$innerWheres = arr();
/** @var ImmutableArray<string|int|float|bool|null> $innerBindings */
$innerBindings = arr();

if ($nestedPath !== null) {
$innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName);
$innerBuilder->whereHas(
relation: $nestedPath,
callback: $callback,
);

$innerWheres = $innerBuilder->wheres;
$innerBindings = arr(input: $innerBuilder->bindings);
} elseif ($callback instanceof Closure) {
$innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName);
$callback($innerBuilder);

$innerWheres = $innerBuilder->wheres;
$innerBindings = arr(input: $innerBuilder->bindings);
}

$whereExists = new WhereExistsStatement(
relatedTable: $existsStatement->relatedTable,
relatedModelName: $existsStatement->relatedModelName,
condition: $existsStatement->condition,
innerWheres: $innerWheres,
negate: $negate,
useCount: $useCount,
operator: $operator,
count: $count,
);

if ($this->wheres->isNotEmpty()) {
$this->appendWhere(where: new WhereStatement(where: $connector->value));
}

$this->appendWhere(where: $whereExists);
$this->bind(...$innerBindings->toArray());

return $this;
}
}
Loading