From 3572c7bd2ba62607582fbd0b3266480e222a69d2 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 17 Apr 2026 19:02:20 +0100 Subject: [PATCH 1/2] test(database): add failing tests for whereHas in WhereGroupBuilder --- .../Builder/WhereHasInWhereGroupTest.php | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php diff --git a/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php b/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php new file mode 100644 index 000000000..fc0ae8931 --- /dev/null +++ b/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php @@ -0,0 +1,156 @@ +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_matching_books(): 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(2, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('Timeline Taxi', $books[1]->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); + 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); + + Isbn::create(value: 'isbn-lotr-1', book: $lotr1); + Isbn::create(value: 'isbn-tt', book: $timelineTaxi); + } +} From f0383cd3cb604d43deba47bfab1ada794d28cc7a Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 17 Apr 2026 19:04:38 +0100 Subject: [PATCH 2/2] feat(database): add whereHas/whereDoesntHave variants to WhereGroupBuilder Enables grouping EXISTS conditions with OR inside parenthesized where groups, e.g. ->whereGroup(fn ($g) => $g->whereHas('a')->orWhereHas('b')) --- .../QueryBuilders/WhereGroupBuilder.php | 165 +++++++++++++++++- .../Builder/WhereHasInWhereGroupTest.php | 10 +- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php index ca6ff0443..466352a0d 100644 --- a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -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; @@ -20,7 +23,7 @@ final class WhereGroupBuilder { use HasConvenientWhereMethods; - /** @var array */ + /** @var array */ private array $conditions = []; /** @var array */ @@ -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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 $innerWheres */ + $innerWheres = arr(); + /** @var ImmutableArray $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. * diff --git a/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php b/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php index fc0ae8931..d29c9cd4f 100644 --- a/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php +++ b/tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php @@ -111,7 +111,7 @@ public function where_has_in_or_where_group_combines_with_outer_where(): void } #[Test] - public function or_where_has_in_where_group_returns_matching_books(): void + public function or_where_has_in_where_group_returns_books_matching_either_relation(): void { $this->seed(); @@ -124,9 +124,10 @@ public function or_where_has_in_where_group_returns_matching_books(): void ->orderBy(field: 'id') ->all(); - $this->assertCount(2, $books); + $this->assertCount(3, $books); $this->assertSame('LOTR 1', $books[0]->title); - $this->assertSame('Timeline Taxi', $books[1]->title); + $this->assertSame('LOTR 2', $books[1]->title); + $this->assertSame('Timeline Taxi', $books[2]->title); } private function seed(): void @@ -144,11 +145,12 @@ private function seed(): void $tolkien = Author::create(name: 'Tolkien'); $lotr1 = Book::create(title: 'LOTR 1', author: $tolkien); - Book::create(title: 'LOTR 2', 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);