Skip to content
Draft
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
165 changes: 164 additions & 1 deletion packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

use Closure;
use Tempest\Database\Builder\ModelInspector;
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 Tempest\Support\Str;

use function Tempest\Support\arr;
Expand All @@ -20,7 +23,7 @@ final class WhereGroupBuilder
{
use HasConvenientWhereMethods;

/** @var array<WhereStatement|WhereGroupStatement> */
/** @var array<WhereStatement|WhereGroupStatement|WhereExistsStatement> */
private array $conditions = [];

/** @var array<mixed> */
Expand Down Expand Up @@ -151,6 +154,166 @@ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self
return $this;
}

/**
* Adds a `WHERE EXISTS` condition for a relation to the group.
* 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 self<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 to the group.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return self<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 to the group.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return self<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 to the group.
*
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
*
* @return self<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,
);
}

/**
* @return self<TModel>
*/
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,
joinStatement: $existsStatement->joinStatement,
innerWheres: $innerWheres,
negate: $negate,
useCount: $useCount,
operator: $operator,
count: $count,
);

if ($this->conditions !== []) {
$this->conditions[] = new WhereStatement($connector->value);
}

$this->conditions[] = $whereExists;
$this->bindings = [...$this->bindings, ...$innerBindings->toArray()];

return $this;
}

/**
* Adds another nested where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements.
*
Expand Down
158 changes: 158 additions & 0 deletions tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Database\Builder;

use PHPUnit\Framework\Attributes\Test;
use Tempest\Database\Builder\QueryBuilders\WhereGroupBuilder;
use Tempest\Database\Migrations\CreateMigrationsTable;
use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable;
use Tests\Tempest\Fixtures\Migrations\CreateBookTable;
use Tests\Tempest\Fixtures\Migrations\CreateChapterTable;
use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable;
use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable;
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter;
use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

/**
* @internal
*/
final class WhereHasInWhereGroupTest extends FrameworkIntegrationTestCase
{
#[Test]
public function where_has_in_where_group_compiles_exists_subquery(): void
{
$sql = Book::select()
->whereGroup(callback: function (WhereGroupBuilder $group): void {
$group->whereHas(relation: 'chapters');
})
->compile();

$this->assertSameWithoutBackticks(
'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id)',
$sql,
);
}

#[Test]
public function or_where_has_in_where_group_compiles_or_exists_wrapped_in_parentheses(): void
{
$sql = Book::select()
->whereGroup(callback: function (WhereGroupBuilder $group): void {
$group
->whereHas(relation: 'chapters')
->orWhereHas(relation: 'isbn');
})
->compile();

$this->assertSameWithoutBackticks(
'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE (EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id) OR EXISTS (SELECT 1 FROM isbns WHERE isbns.book_id = books.id))',
$sql,
);
}

#[Test]
public function or_where_doesnt_have_in_where_group_compiles_or_not_exists(): void
{
$sql = Book::select()
->whereGroup(callback: function (WhereGroupBuilder $group): void {
$group
->whereHas(relation: 'chapters')
->orWhereDoesntHave(relation: 'isbn');
})
->compile();

$this->assertSameWithoutBackticks(
'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE (EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id) OR NOT EXISTS (SELECT 1 FROM isbns WHERE isbns.book_id = books.id))',
$sql,
);
}

#[Test]
public function where_has_in_where_group_with_callback_compiles_constrained_subquery(): void
{
$sql = Book::select()
->whereGroup(callback: function (WhereGroupBuilder $group): void {
$group->whereHas(
relation: 'chapters',
callback: function ($query): void {
$query->whereField(field: 'title', value: 'Chapter 1');
},
);
})
->compile();

$this->assertSameWithoutBackticks(
'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id AND chapters.title = ?)',
$sql,
);
}

#[Test]
public function where_has_in_or_where_group_combines_with_outer_where(): void
{
$sql = Book::select()
->whereField(field: 'title', value: 'LOTR 1')
->orWhereGroup(callback: function (WhereGroupBuilder $group): void {
$group
->whereHas(relation: 'chapters')
->orWhereHas(relation: 'isbn');
})
->compile();

$this->assertSameWithoutBackticks(
'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE books.title = ? OR (EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id) OR EXISTS (SELECT 1 FROM isbns WHERE isbns.book_id = books.id))',
$sql,
);
}

#[Test]
public function or_where_has_in_where_group_returns_books_matching_either_relation(): void
{
$this->seed();

$books = Book::select()
->whereGroup(callback: function (WhereGroupBuilder $group): void {
$group
->whereHas(relation: 'chapters')
->orWhereHas(relation: 'isbn');
})
->orderBy(field: 'id')
->all();

$this->assertCount(3, $books);
$this->assertSame('LOTR 1', $books[0]->title);
$this->assertSame('LOTR 2', $books[1]->title);
$this->assertSame('Timeline Taxi', $books[2]->title);
}

private function seed(): void
{
$this->database->migrate(
CreateMigrationsTable::class,
CreatePublishersTable::class,
CreateAuthorTable::class,
CreateBookTable::class,
CreateChapterTable::class,
CreateIsbnTable::class,
);

$brent = Author::create(name: 'Brent');
$tolkien = Author::create(name: 'Tolkien');

$lotr1 = Book::create(title: 'LOTR 1', author: $tolkien);
$lotr2 = Book::create(title: 'LOTR 2', author: $tolkien);
Book::create(title: 'LOTR 3', author: $tolkien);
$timelineTaxi = Book::create(title: 'Timeline Taxi', author: $brent);

Chapter::create(title: 'Chapter 1', book: $lotr1);
Chapter::create(title: 'Chapter 1', book: $lotr2);

Isbn::create(value: 'isbn-lotr-1', book: $lotr1);
Isbn::create(value: 'isbn-tt', book: $timelineTaxi);
}
}
Loading