44
55use Closure ;
66use Tempest \Database \Builder \ModelInspector ;
7+ use Tempest \Database \Builder \WhereConnector ;
78use Tempest \Database \Builder \WhereOperator ;
9+ use Tempest \Database \QueryStatements \WhereExistsStatement ;
810use Tempest \Database \QueryStatements \WhereGroupStatement ;
911use Tempest \Database \QueryStatements \WhereStatement ;
12+ use Tempest \Support \Arr \ImmutableArray ;
1013use Tempest \Support \Str ;
1114
1215use 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 *
0 commit comments