Skip to content

Commit c44fee0

Browse files
committed
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'))
1 parent db8078a commit c44fee0

2 files changed

Lines changed: 170 additions & 5 deletions

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
*

tests/Integration/Database/Builder/WhereHasInWhereGroupTest.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public function where_has_in_or_where_group_combines_with_outer_where(): void
111111
}
112112

113113
#[Test]
114-
public function or_where_has_in_where_group_returns_matching_books(): void
114+
public function or_where_has_in_where_group_returns_books_matching_either_relation(): void
115115
{
116116
$this->seed();
117117

@@ -124,9 +124,10 @@ public function or_where_has_in_where_group_returns_matching_books(): void
124124
->orderBy(field: 'id')
125125
->all();
126126

127-
$this->assertCount(2, $books);
127+
$this->assertCount(3, $books);
128128
$this->assertSame('LOTR 1', $books[0]->title);
129-
$this->assertSame('Timeline Taxi', $books[1]->title);
129+
$this->assertSame('LOTR 2', $books[1]->title);
130+
$this->assertSame('Timeline Taxi', $books[2]->title);
130131
}
131132

132133
private function seed(): void
@@ -144,11 +145,12 @@ private function seed(): void
144145
$tolkien = Author::create(name: 'Tolkien');
145146

146147
$lotr1 = Book::create(title: 'LOTR 1', author: $tolkien);
147-
Book::create(title: 'LOTR 2', author: $tolkien);
148+
$lotr2 = Book::create(title: 'LOTR 2', author: $tolkien);
148149
Book::create(title: 'LOTR 3', author: $tolkien);
149150
$timelineTaxi = Book::create(title: 'Timeline Taxi', author: $brent);
150151

151152
Chapter::create(title: 'Chapter 1', book: $lotr1);
153+
Chapter::create(title: 'Chapter 1', book: $lotr2);
152154

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

0 commit comments

Comments
 (0)