Skip to content

Commit 851048a

Browse files
authored
feat(database): add support for whereHas inside where groups (#2121)
1 parent ab66cb2 commit 851048a

2 files changed

Lines changed: 322 additions & 1 deletion

File tree

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

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
use Closure;
66
use Tempest\Database\Builder\ModelInspector;
7+
use Tempest\Database\Builder\WhereConnector;
78
use Tempest\Database\Builder\WhereOperator;
9+
use Tempest\Database\QueryStatements\WhereExistsStatement;
810
use Tempest\Database\QueryStatements\WhereGroupStatement;
911
use Tempest\Database\QueryStatements\WhereStatement;
12+
use Tempest\Support\Arr\ImmutableArray;
1013
use Tempest\Support\Str;
1114

1215
use function Tempest\Support\arr;
@@ -20,7 +23,7 @@ final class WhereGroupBuilder
2023
{
2124
use HasConvenientWhereMethods;
2225

23-
/** @var array<WhereStatement|WhereGroupStatement> */
26+
/** @var array<WhereStatement|WhereGroupStatement|WhereExistsStatement> */
2427
private array $conditions = [];
2528

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

157+
/**
158+
* Adds a `WHERE EXISTS` condition for a relation to the group.
159+
* When `$count` is 1 and `$operator` is `>=` (defaults), uses an `EXISTS` subquery.
160+
* Otherwise, uses a `COUNT(*)` subquery with the given operator and count.
161+
*
162+
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
163+
*
164+
* @return self<TModel>
165+
*/
166+
public function whereHas(
167+
string $relation,
168+
?Closure $callback = null,
169+
string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL,
170+
int $count = 1,
171+
): self {
172+
return $this->addHasCondition(
173+
relation: $relation,
174+
callback: $callback,
175+
operator: WhereOperator::fromOperator(value: $operator),
176+
count: $count,
177+
connector: WhereConnector::AND,
178+
negate: false,
179+
);
180+
}
181+
182+
/**
183+
* Adds a `WHERE NOT EXISTS` condition for a relation to the group.
184+
*
185+
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
186+
*
187+
* @return self<TModel>
188+
*/
189+
public function whereDoesntHave(
190+
string $relation,
191+
?Closure $callback = null,
192+
): self {
193+
return $this->addHasCondition(
194+
relation: $relation,
195+
callback: $callback,
196+
operator: WhereOperator::GREATER_THAN_OR_EQUAL,
197+
count: 1,
198+
connector: WhereConnector::AND,
199+
negate: true,
200+
);
201+
}
202+
203+
/**
204+
* Adds an `OR WHERE EXISTS` condition for a relation to the group.
205+
*
206+
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
207+
*
208+
* @return self<TModel>
209+
*/
210+
public function orWhereHas(
211+
string $relation,
212+
?Closure $callback = null,
213+
string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL,
214+
int $count = 1,
215+
): self {
216+
return $this->addHasCondition(
217+
relation: $relation,
218+
callback: $callback,
219+
operator: WhereOperator::fromOperator(value: $operator),
220+
count: $count,
221+
connector: WhereConnector::OR,
222+
negate: false,
223+
);
224+
}
225+
226+
/**
227+
* Adds an `OR WHERE NOT EXISTS` condition for a relation to the group.
228+
*
229+
* @phpstan-param (?Closure(SelectQueryBuilder): void) $callback
230+
*
231+
* @return self<TModel>
232+
*/
233+
public function orWhereDoesntHave(
234+
string $relation,
235+
?Closure $callback = null,
236+
): self {
237+
return $this->addHasCondition(
238+
relation: $relation,
239+
callback: $callback,
240+
operator: WhereOperator::GREATER_THAN_OR_EQUAL,
241+
count: 1,
242+
connector: WhereConnector::OR,
243+
negate: true,
244+
);
245+
}
246+
247+
/**
248+
* @return self<TModel>
249+
*/
250+
private function addHasCondition(
251+
string $relation,
252+
?Closure $callback,
253+
WhereOperator $operator,
254+
int $count,
255+
WhereConnector $connector,
256+
bool $negate,
257+
): self {
258+
$parts = str(string: $relation)->explode(
259+
separator: '.',
260+
limit: 2,
261+
);
262+
$relationName = (string) $parts[0];
263+
$nestedPath = isset($parts[1])
264+
? (string) $parts[1]
265+
: null;
266+
267+
$existsStatement = $this->model
268+
->getRelation(name: $relationName)
269+
->getExistsStatement();
270+
271+
$useCount = ! $negate && ($count !== 1 || $operator !== WhereOperator::GREATER_THAN_OR_EQUAL);
272+
273+
/** @var ImmutableArray<WhereStatement|WhereGroupStatement|WhereExistsStatement> $innerWheres */
274+
$innerWheres = arr();
275+
/** @var ImmutableArray<string|int|float|bool|null> $innerBindings */
276+
$innerBindings = arr();
277+
278+
if ($nestedPath !== null) {
279+
$innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName);
280+
$innerBuilder->whereHas(
281+
relation: $nestedPath,
282+
callback: $callback,
283+
);
284+
285+
$innerWheres = $innerBuilder->wheres;
286+
$innerBindings = arr(input: $innerBuilder->bindings);
287+
} elseif ($callback instanceof Closure) {
288+
$innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName);
289+
$callback($innerBuilder);
290+
291+
$innerWheres = $innerBuilder->wheres;
292+
$innerBindings = arr(input: $innerBuilder->bindings);
293+
}
294+
295+
$whereExists = new WhereExistsStatement(
296+
relatedTable: $existsStatement->relatedTable,
297+
relatedModelName: $existsStatement->relatedModelName,
298+
condition: $existsStatement->condition,
299+
joinStatement: $existsStatement->joinStatement,
300+
innerWheres: $innerWheres,
301+
negate: $negate,
302+
useCount: $useCount,
303+
operator: $operator,
304+
count: $count,
305+
);
306+
307+
if ($this->conditions !== []) {
308+
$this->conditions[] = new WhereStatement($connector->value);
309+
}
310+
311+
$this->conditions[] = $whereExists;
312+
$this->bindings = [...$this->bindings, ...$innerBindings->toArray()];
313+
314+
return $this;
315+
}
316+
154317
/**
155318
* Adds another nested where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements.
156319
*
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Database\Builder;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Tempest\Database\Builder\QueryBuilders\WhereGroupBuilder;
9+
use Tempest\Database\Migrations\CreateMigrationsTable;
10+
use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable;
11+
use Tests\Tempest\Fixtures\Migrations\CreateBookTable;
12+
use Tests\Tempest\Fixtures\Migrations\CreateChapterTable;
13+
use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable;
14+
use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable;
15+
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
16+
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
17+
use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter;
18+
use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn;
19+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
20+
21+
/**
22+
* @internal
23+
*/
24+
final class WhereHasInWhereGroupTest extends FrameworkIntegrationTestCase
25+
{
26+
#[Test]
27+
public function where_has_in_where_group_compiles_exists_subquery(): void
28+
{
29+
$sql = Book::select()
30+
->whereGroup(callback: function (WhereGroupBuilder $group): void {
31+
$group->whereHas(relation: 'chapters');
32+
})
33+
->compile();
34+
35+
$this->assertSameWithoutBackticks(
36+
'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)',
37+
$sql,
38+
);
39+
}
40+
41+
#[Test]
42+
public function or_where_has_in_where_group_compiles_or_exists_wrapped_in_parentheses(): void
43+
{
44+
$sql = Book::select()
45+
->whereGroup(callback: function (WhereGroupBuilder $group): void {
46+
$group
47+
->whereHas(relation: 'chapters')
48+
->orWhereHas(relation: 'isbn');
49+
})
50+
->compile();
51+
52+
$this->assertSameWithoutBackticks(
53+
'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))',
54+
$sql,
55+
);
56+
}
57+
58+
#[Test]
59+
public function or_where_doesnt_have_in_where_group_compiles_or_not_exists(): void
60+
{
61+
$sql = Book::select()
62+
->whereGroup(callback: function (WhereGroupBuilder $group): void {
63+
$group
64+
->whereHas(relation: 'chapters')
65+
->orWhereDoesntHave(relation: 'isbn');
66+
})
67+
->compile();
68+
69+
$this->assertSameWithoutBackticks(
70+
'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))',
71+
$sql,
72+
);
73+
}
74+
75+
#[Test]
76+
public function where_has_in_where_group_with_callback_compiles_constrained_subquery(): void
77+
{
78+
$sql = Book::select()
79+
->whereGroup(callback: function (WhereGroupBuilder $group): void {
80+
$group->whereHas(
81+
relation: 'chapters',
82+
callback: function ($query): void {
83+
$query->whereField(field: 'title', value: 'Chapter 1');
84+
},
85+
);
86+
})
87+
->compile();
88+
89+
$this->assertSameWithoutBackticks(
90+
'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 = ?)',
91+
$sql,
92+
);
93+
}
94+
95+
#[Test]
96+
public function where_has_in_or_where_group_combines_with_outer_where(): void
97+
{
98+
$sql = Book::select()
99+
->whereField(field: 'title', value: 'LOTR 1')
100+
->orWhereGroup(callback: function (WhereGroupBuilder $group): void {
101+
$group
102+
->whereHas(relation: 'chapters')
103+
->orWhereHas(relation: 'isbn');
104+
})
105+
->compile();
106+
107+
$this->assertSameWithoutBackticks(
108+
'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))',
109+
$sql,
110+
);
111+
}
112+
113+
#[Test]
114+
public function or_where_has_in_where_group_returns_books_matching_either_relation(): void
115+
{
116+
$this->seed();
117+
118+
$books = Book::select()
119+
->whereGroup(callback: function (WhereGroupBuilder $group): void {
120+
$group
121+
->whereHas(relation: 'chapters')
122+
->orWhereHas(relation: 'isbn');
123+
})
124+
->orderBy(field: 'id')
125+
->all();
126+
127+
$this->assertCount(3, $books);
128+
$this->assertSame('LOTR 1', $books[0]->title);
129+
$this->assertSame('LOTR 2', $books[1]->title);
130+
$this->assertSame('Timeline Taxi', $books[2]->title);
131+
}
132+
133+
private function seed(): void
134+
{
135+
$this->database->migrate(
136+
CreateMigrationsTable::class,
137+
CreatePublishersTable::class,
138+
CreateAuthorTable::class,
139+
CreateBookTable::class,
140+
CreateChapterTable::class,
141+
CreateIsbnTable::class,
142+
);
143+
144+
$brent = Author::create(name: 'Brent');
145+
$tolkien = Author::create(name: 'Tolkien');
146+
147+
$lotr1 = Book::create(title: 'LOTR 1', author: $tolkien);
148+
$lotr2 = Book::create(title: 'LOTR 2', author: $tolkien);
149+
Book::create(title: 'LOTR 3', author: $tolkien);
150+
$timelineTaxi = Book::create(title: 'Timeline Taxi', author: $brent);
151+
152+
Chapter::create(title: 'Chapter 1', book: $lotr1);
153+
Chapter::create(title: 'Chapter 1', book: $lotr2);
154+
155+
Isbn::create(value: 'isbn-lotr-1', book: $lotr1);
156+
Isbn::create(value: 'isbn-tt', book: $timelineTaxi);
157+
}
158+
}

0 commit comments

Comments
 (0)