Lines 96.36% 292 / 303
Methods 88.17% 82 / 93
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 normalizeTableReference 100.00% 6 / 6 100.00% 1 / 1 3
 select 100.00% 8 / 8 100.00% 1 / 1 4
 distinct 100.00% 3 / 3 100.00% 1 / 1 1
 fromSub 100.00% 7 / 7 100.00% 1 / 1 2
 join 100.00% 1 / 1 100.00% 1 / 1 1
 leftJoin 100.00% 1 / 1 100.00% 1 / 1 1
 rightJoin 100.00% 1 / 1 100.00% 1 / 1 1
 crossJoin 100.00% 1 / 1 100.00% 1 / 1 1
 on 100.00% 1 / 1 100.00% 1 / 1 1
 orOn 100.00% 1 / 1 100.00% 1 / 1 1
 addOn 100.00% 15 / 15 100.00% 1 / 1 3
 addJoin 100.00% 16 / 16 100.00% 1 / 1 3
 assertHomogeneousList 100.00% 12 / 12 100.00% 1 / 1 6
 where 100.00% 54 / 54 100.00% 1 / 1 20
 whereRaw 100.00% 4 / 4 100.00% 1 / 1 1
 whereColumn 100.00% 4 / 4 100.00% 1 / 1 2
 whereExists 100.00% 4 / 4 100.00% 1 / 1 1
 whereNotExists 100.00% 1 / 1 100.00% 1 / 1 1
 orWhereExists 100.00% 1 / 1 100.00% 1 / 1 1
 orWhereNotExists 0.00% 0 / 1 0.00% 0 / 1 2
 whereInQuery 100.00% 4 / 4 100.00% 1 / 1 1
 whereNotInQuery 100.00% 1 / 1 100.00% 1 / 1 1
 orWhereInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 orWhereNotInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 whereNested 100.00% 16 / 16 100.00% 1 / 1 3
 newNestedBuilder 100.00% 1 / 1 100.00% 1 / 1 1
 groupBy 100.00% 3 / 3 100.00% 1 / 1 2
 having 100.00% 6 / 6 100.00% 1 / 1 5
 orderBy 100.00% 6 / 6 100.00% 1 / 1 2
 limit 100.00% 3 / 3 100.00% 1 / 1 1
 offset 100.00% 3 / 3 100.00% 1 / 1 1
 union 100.00% 4 / 4 100.00% 1 / 1 1
 lockForUpdate 100.00% 3 / 3 100.00% 1 / 1 1
 sharedLock 100.00% 3 / 3 100.00% 1 / 1 1
 get 100.00% 1 / 1 100.00% 1 / 1 1
 cursor 100.00% 1 / 1 100.00% 1 / 1 1
 first 100.00% 1 / 1 100.00% 1 / 1 1
 value 83.33% 5 / 6 0.00% 0 / 1 2.02
 pluck 100.00% 1 / 1 100.00% 1 / 1 1
 scalarColumn 100.00% 1 / 1 100.00% 1 / 1 1
 count 100.00% 1 / 1 100.00% 1 / 1 1
 exists 100.00% 1 / 1 100.00% 1 / 1 1
 max 100.00% 1 / 1 100.00% 1 / 1 1
 min 100.00% 1 / 1 100.00% 1 / 1 1
 sum 100.00% 1 / 1 100.00% 1 / 1 1
 avg 100.00% 1 / 1 100.00% 1 / 1 1
 aggregates 100.00% 5 / 5 100.00% 1 / 1 1
 aggregateBy 100.00% 11 / 11 100.00% 1 / 1 3
 countBy 100.00% 8 / 8 100.00% 1 / 1 3
 insert 100.00% 1 / 1 100.00% 1 / 1 1
 insertGetId 100.00% 1 / 1 100.00% 1 / 1 1
 update 100.00% 1 / 1 100.00% 1 / 1 1
 delete 100.00% 1 / 1 100.00% 1 / 1 1
 getBindings 100.00% 6 / 6 100.00% 1 / 1 3
 insertIdColumn 100.00% 4 / 4 100.00% 1 / 1 1
 assertSupports 100.00% 11 / 11 100.00% 1 / 1 2
 getColumns 100.00% 1 / 1 100.00% 1 / 1 1
 isDistinct 100.00% 1 / 1 100.00% 1 / 1 1
 getFrom 100.00% 1 / 1 100.00% 1 / 1 1
 getFromAlias 100.00% 1 / 1 100.00% 1 / 1 1
 getJoins 100.00% 1 / 1 100.00% 1 / 1 1
 getWheres 100.00% 1 / 1 100.00% 1 / 1 1
 markLastWhereTraitScope 75.00% 3 / 4 0.00% 0 / 1 2.06
 getGroups 100.00% 1 / 1 100.00% 1 / 1 1
 getHavings 100.00% 1 / 1 100.00% 1 / 1 1
 getOrders 100.00% 1 / 1 100.00% 1 / 1 1
 getUnions 100.00% 1 / 1 100.00% 1 / 1 1
 getLock 100.00% 1 / 1 100.00% 1 / 1 1
 getLimit 100.00% 1 / 1 100.00% 1 / 1 1
 getOffset 100.00% 1 / 1 100.00% 1 / 1 1
 getInsertIdColumn 100.00% 1 / 1 100.00% 1 / 1 1
 isInsertIdAutoIncrement 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] where n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereEq 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereEq 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNested n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNestedGroup 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereNested 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereExists n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNotExists 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereExists 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereNotExists 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereInQuery n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNotInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereNotInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhere 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereIn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNotIn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNull 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNotNull 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereBetween 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNotBetween 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereLike 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] orWhereLike 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersWhere] whereNotLike 100.00% 1 / 1 100.00% 1 / 1 1
32class QueryBuilder
33{
34    use FiltersWhere;
35
36    /**
37     * The internal result alias for a grouped aggregate's value column.
38     *
39     * The same convention as the scalar reads: a stable alias keeps the
40     * value readable regardless of how each driver names an unaliased
41     * aggregate column.
42     */
43    protected const AGGREGATE_ALIAS = 'radiant_aggregate';
44
45    /**
46     * The columns to select.
47     *
48     * @var list<string|Expression|Aggregate|SubquerySelect>
49     */
50    protected array $columns = ['*'];
51
52    /**
53     * Whether the select is `DISTINCT`.
54     *
55     * @var bool
56     */
57    protected bool $distinct = false;
58
59    /**
60     * The from clause — a table name or a subquery builder.
61     *
62     * @var string|QueryBuilder
63     */
64    protected string|QueryBuilder $from;
65
66    /**
67     * The alias of the from subquery, when `fromSub()` was used.
68     *
69     * @var string|null
70     */
71    protected ?string $fromAlias = null;
72
73    /**
74     * The joins to apply.
75     *
76     * @var list<array{type: JoinType, table: string, wheres: list<array{type: WhereType::Column, first: string, operator: ColumnOperator, second: string, boolean: WhereBoolean}>}>
77     */
78    protected array $joins = [];
79
80    /**
81     * The where clauses.
82     *
83     * @var list<WhereClause>
84     */
85    protected array $wheres = [];
86
87    /**
88     * The group-by columns.
89     *
90     * @var list<string>
91     */
92    protected array $groups = [];
93
94    /**
95     * The having clauses.
96     *
97     * The compared `column` may be a plain column, an {@see Expression}, or
98     * an {@see Aggregate} (filtering on a computed value —
99     * `HAVING count(*) > ?`).
100     *
101     * @var list<array{type: WhereType::Basic, column: string|Expression|Aggregate, operator: WhereOperator, value: BindingValue}>
102     */
103    protected array $havings = [];
104
105    /**
106     * The order-by clauses.
107     *
108     * An {@see Expression} column is spliced verbatim; its direction is
109     * still validated and appended after it.
110     *
111     * @var list<array{column: string|Expression, direction: SortDirection|null}>
112     */
113    protected array $orders = [];
114
115    /**
116     * The unions to append.
117     *
118     * @var list<array{query: QueryBuilder, all: bool}>
119     */
120    protected array $unions = [];
121
122    /**
123     * The row lock to apply, or null for none.
124     *
125     * @var LockType|null
126     */
127    protected ?LockType $lock = null;
128
129    /**
130     * The maximum number of rows to return.
131     *
132     * @var int|null
133     */
134    protected ?int $limit = null;
135
136    /**
137     * The number of rows to skip.
138     *
139     * @var int|null
140     */
141    protected ?int $offset = null;
142
143    /**
144     * The PK column to return on insert, if known (null for raw).
145     *
146     * @var string|null
147     */
148    protected ?string $insertIdColumn = null;
149
150    /**
151     * Whether the insert-id column is auto-increment (server-generated).
152     *
153     * @var bool|null
154     */
155    protected ?bool $insertIdAutoIncrement = null;
156
157    /**
158     * Bindings grouped by the clause they belong to.
159     *
160     * @var array<string, list<BindingValue>>
161     */
162    protected array $bindings = [
163        BindingCategory::Select->value => [],
164        BindingCategory::From->value => [],
165        BindingCategory::Join->value => [],
166        BindingCategory::Where->value => [],
167        BindingCategory::GroupBy->value => [],
168        BindingCategory::Having->value => [],
169        BindingCategory::Order->value => [],
170        BindingCategory::Union->value => [],
171        BindingCategory::Lock->value => [],
172    ];
173
174    /**
175     * Create a new query builder bound to a table on a connection.
176     *
177     * @param  ConnectionInterface  $connection
178     * @param  string  $table
179     */
180    public function __construct(
181        public readonly ConnectionInterface $connection,
182        public readonly string $table,
183    ) {
184        $this->from = $this->normalizeTableReference($table);
185    }
186
187    /**
188     * Validate a table reference.
189     *
190     * A reference containing whitespace must use the `table as alias`
191     * spelling — the compact `profiles p1` form is rejected, so a
192     * mistyped table name cannot become a phantom alias.
193     *
194     * @param  string  $table
195     * @return string
196     * @throws \InvalidArgumentException
197     */
198    protected function normalizeTableReference(string $table): string
199    {
200        if (preg_match('/\s+as\s+/i', $table) !== 1 && preg_match('/\s/', $table) === 1) {
201            throw new \InvalidArgumentException(
202                "Invalid table reference [{$table}] — use `table` or `table as alias`"
203                . ' (the compact `table alias` spelling is not accepted).'
204            );
205        }
206
207        return $table;
208    }
209
210    // ---- Selection ----
211
212    /**
213     * Set the columns to select.
214     *
215     * Calling with no arguments resets to the `['*']` default select.
216     * A {@see SubquerySelect} node selects a scalar subquery; its
217     * sub-builder bindings ride the Select category, derived from the
218     * column list on every call so the bucket cannot drift.
219     *
220     * @param  string|Expression|Aggregate|SubquerySelect  ...$columns
221     * @return static
222     *
223     * @throws \InvalidArgumentException
224     */
225    public function select(string|Expression|Aggregate|SubquerySelect ...$columns): static
226    {
227        $clone = clone $this;
228        $clone->columns = $columns === [] ? ['*'] : array_values($columns);
229
230        $selectBindings = [];
231        foreach ($clone->columns as $column) {
232            if ($column instanceof SubquerySelect) {
233                array_push($selectBindings, ...$column->bindings());
234            }
235        }
236        $clone->bindings[BindingCategory::Select->value] = $selectBindings;
237
238        return $clone;
239    }
240
241    /**
242     * Make the select distinct.
243     *
244     * @return static
245     */
246    final public function distinct(): static
247    {
248        $clone = clone $this;
249        $clone->distinct = true;
250        return $clone;
251    }
252
253    // ---- From ----
254
255    /**
256     * Set the from clause to a subquery.
257     *
258     * @param  QueryBuilder  $query
259     * @param  string  $alias
260     * @return static
261     *
262     * @throws \LogicException
263     */
264    final public function fromSub(QueryBuilder $query, string $alias): static
265    {
266        if ($this->from instanceof QueryBuilder) {
267            throw new \LogicException('The query from is already set and cannot be changed.');
268        }
269        $clone = clone $this;
270        $clone->from = $query;
271        $clone->fromAlias = $alias;
272        // Eager capture: the sub-builder's full binding list rides the
273        // From category of the RETURNED clone (the subquery's `?`s all sit
274        // inside the compiled from clause, so their order is exactly the
275        // sub-builder's flattened order).
276        $clone->bindings[BindingCategory::From->value] = $query->getBindings();
277        return $clone;
278    }
279
280    // ---- Joins ----
281
282    /**
283     * Add an inner join to the query.
284     *
285     * @param  string  $table
286     * @param  string  $first
287     * @param  ColumnOperator|string  $operator
288     * @param  string  $second
289     * @return static
290     */
291    public function join(string $table, string $first, ColumnOperator|string $operator = '=', string $second = ''): static
292    {
293        return $this->addJoin(JoinType::Inner, $table, $first, $operator, $second);
294    }
295
296    /**
297     * Add a left join to the query.
298     *
299     * @param  string  $table
300     * @param  string  $first
301     * @param  ColumnOperator|string  $operator
302     * @param  string  $second
303     * @return static
304     */
305    public function leftJoin(string $table, string $first, ColumnOperator|string $operator = '=', string $second = ''): static
306    {
307        return $this->addJoin(JoinType::Left, $table, $first, $operator, $second);
308    }
309
310    /**
311     * Add a right join to the query.
312     *
313     * @param  string  $table
314     * @param  string  $first
315     * @param  ColumnOperator|string  $operator
316     * @param  string  $second
317     * @return static
318     */
319    public function rightJoin(string $table, string $first, ColumnOperator|string $operator = '=', string $second = ''): static
320    {
321        return $this->addJoin(JoinType::Right, $table, $first, $operator, $second);
322    }
323
324    /**
325     * Add a cross join to the query.
326     *
327     * @param  string  $table
328     * @return static
329     */
330    final public function crossJoin(string $table): static
331    {
332        return $this->addJoin(JoinType::Cross, $table, '', '=', '');
333    }
334
335    /**
336     * Add an additional ON condition to the most recent join.
337     *
338     * Conditions are strictly column-to-column — values belong in where()
339     * after the join, not in the ON clause.
340     *
341     * @param  string  $first
342     * @param  ColumnOperator|string  $operator
343     * @param  string  $second
344     * @return static
345     *
346     * @throws \LogicException
347     * @throws \InvalidArgumentException
348     */
349    public function on(string $first, ColumnOperator|string $operator = '=', string $second = ''): static
350    {
351        return $this->addOn(WhereBoolean::And, $first, $operator, $second);
352    }
353
354    /**
355     * Add an additional OR-connected ON condition to the most recent join.
356     *
357     * @param  string  $first
358     * @param  ColumnOperator|string  $operator
359     * @param  string  $second
360     * @return static
361     *
362     * @throws \LogicException
363     * @throws \InvalidArgumentException
364     */
365    public function orOn(string $first, ColumnOperator|string $operator = '=', string $second = ''): static
366    {
367        return $this->addOn(WhereBoolean::Or, $first, $operator, $second);
368    }
369
370    /**
371     * Add an ON condition to the last added join.
372     *
373     * @param  WhereBoolean  $boolean
374     * @param  string  $first
375     * @param  ColumnOperator|string  $operator
376     * @param  string  $second
377     * @return static
378     *
379     * @throws \LogicException
380     * @throws \InvalidArgumentException
381     */
382    protected function addOn(WhereBoolean $boolean, string $first, ColumnOperator|string $operator, string $second): static
383    {
384        if ($this->joins === []) {
385            throw new \LogicException(
386                'Cannot call on()/orOn() before a join: an ON condition belongs to the join it follows. Call join()/leftJoin()/rightJoin()/crossJoin() first.'
387            );
388        }
389
390        $resolved = $operator instanceof ColumnOperator ? $operator : ColumnOperator::fromChecked($operator);
391        $last = count($this->joins) - 1;
392        $clone = clone $this;
393        $clone->joins[$last]['wheres'][] = [
394            'type' => WhereType::Column,
395            'first' => $first,
396            'operator' => $resolved,
397            'second' => $second,
398            'boolean' => $boolean,
399        ];
400        return $clone;
401    }
402
403    /**
404     * Add a join clause to the query.
405     *
406     * @param  JoinType  $type
407     * @param  string  $table
408     * @param  string  $first
409     * @param  ColumnOperator|string  $operator
410     * @param  string  $second
411     * @return static
412     *
413     * @throws \InvalidArgumentException
414     */
415    protected function addJoin(JoinType $type, string $table, string $first, ColumnOperator|string $operator, string $second): static
416    {
417        $resolved = $operator instanceof ColumnOperator ? $operator : ColumnOperator::fromChecked($operator);
418        $clone = clone $this;
419        $clone->joins[] = [
420            'type' => $type,
421            'table' => $this->normalizeTableReference($table),
422            'wheres' => $second === ''
423                ? []
424                : [[
425                    'type' => WhereType::Column,
426                    'first' => $first,
427                    'operator' => $resolved,
428                    'second' => $second,
429                    'boolean' => WhereBoolean::And,
430                ]],
431        ];
432        return $clone;
433    }
434
435    /**
436     * Assert a list is homogeneous — all plain bindable values, or ALL raw
437     * (Expression/ToSqlValue).
438     *
439     * A mixed list desyncs placeholders from bindings on the IN/BETWEEN
440     * compile shapes.
441     *
442     * @param  array<int, mixed>  $value
443     * @param  string  $method
444     * @return void
445     * @throws \InvalidArgumentException
446     */
447    private function assertHomogeneousList(array $value, string $method): void
448    {
449        $hasRaw = false;
450        $hasPlain = false;
451        foreach ($value as $item) {
452            if ($item instanceof Expression || $item instanceof ToSqlValue) {
453                $hasRaw = true;
454            } else {
455                $hasPlain = true;
456            }
457
458            if ($hasRaw && $hasPlain) {
459                throw new \InvalidArgumentException(
460                    "{$method} require a list of ALL plain values or ALL raw SQL expressions "
461                    . '(Expression/ToSqlValue) — a mixed list desyncs placeholders from bindings. '
462                    . 'Split into separate clauses or normalize the list.'
463                );
464            }
465        }
466    }
467
468    // ---- Wheres ----
469
470    /**
471     * Add a where clause to the query.
472     *
473     * The value is polymorphic by design: a bindable scalar, a list (for
474     * IN/NOT IN/BETWEEN), or a composite column => value key map. The
475     * operator branches below validate each shape at declaration.
476     *
477     * @param  string|Expression  $column
478     * @param  WhereOperator|string  $operator
479     * @param  mixed  $value
480     * @param  WhereBoolean  $boolean
481     * @return static
482     *
483     * @throws \InvalidArgumentException
484     */
485    public function where(string|Expression $column, WhereOperator|string $operator, mixed $value, WhereBoolean $boolean = WhereBoolean::And): static
486    {
487        $operator = $operator instanceof WhereOperator ? $operator : WhereOperator::fromChecked($operator);
488
489        if ($operator === WhereOperator::In || $operator === WhereOperator::NotIn) {
490            if (!is_array($value)) {
491                throw new \InvalidArgumentException(
492                    'whereIn()/whereNotIn() require an array of values; got ' . get_debug_type($value) . '.'
493                );
494            }
495            if ($value === []) {
496                throw new \InvalidArgumentException(
497                    'whereIn()/whereNotIn() require a non-empty array of values; '
498                    . 'an empty list compiles to invalid SQL. Filter in PHP or skip the clause instead.'
499                );
500            }
501            // Every list element must be the SAME kind — a plain bindable
502            // value or a raw Expression/ToSqlValue. A MIXED list desyncs
503            // placeholders from bindings: the grammar renders one `?` per
504            // element, but the binding filter drops the raw ones, so the
505            // driver receives fewer values than placeholders (a hard
506            // QueryException on strict drivers, silent mis-binding on lax
507            // ones). Fail fast at declaration.
508            $this->assertHomogeneousList($value, 'whereIn()/whereNotIn()');
509            $clone = clone $this;
510            $clone->wheres[] = ['type' => WhereType::Basic, 'column' => $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean];
511            array_push($clone->bindings[BindingCategory::Where->value], ...array_filter(
512                $value,
513                fn ($item) => !$item instanceof Expression && !$item instanceof ToSqlValue,
514            ));
515            return $clone;
516        }
517
518        if ($operator === WhereOperator::Between || $operator === WhereOperator::NotBetween) {
519            // A scalar (or a list of the wrong arity) cannot compile to a
520            // BETWEEN — fail fast at declaration with the shape named.
521            if (!is_array($value) || count($value) !== 2) {
522                throw new \InvalidArgumentException(
523                    'whereBetween()/whereNotBetween() require a two-value [min, max] array; got '
524                        . get_debug_type($value) . '.'
525                );
526            }
527
528            // Same homogeneity contract as the IN lists above — a mixed
529            // scalar/Expression pair (e.g. [new Expression('NOW()'), $end])
530            // desyncs placeholders from bindings identically.
531            $this->assertHomogeneousList($value, 'whereBetween()/whereNotBetween()');
532            $clone = clone $this;
533            $clone->wheres[] = ['type' => WhereType::Between, 'column' => $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean];
534            array_push($clone->bindings[BindingCategory::Where->value], ...array_filter(
535                $value,
536                fn ($item) => !$item instanceof Expression && !$item instanceof ToSqlValue,
537            ));
538            return $clone;
539        }
540
541        if ($operator === WhereOperator::Null || $operator === WhereOperator::NotNull) {
542            $clone = clone $this;
543            $clone->wheres[] = ['type' => WhereType::Null, 'column' => $column, 'operator' => $operator, 'boolean' => $boolean];
544            return $clone;
545        }
546
547        // A null value with a comparison operator can never match: SQL
548        // `col = NULL` (and every other comparison against NULL) is UNKNOWN,
549        // so the clause compiles to an unbound `= ?` and silently filters
550        // everything out. The intent is always IS NULL / IS NOT NULL — say
551        // so, fail fast, and name the correct method.
552        if ($value === null) {
553            $label = is_string($column) ? $column : $column->value;
554            throw new \InvalidArgumentException(
555                "where('{$label}', '{$operator->value}', null) can never match — SQL comparisons against NULL"
556                . ' are UNKNOWN. Use whereNull(\'' . $label . '\') or whereNotNull(\'' . $label . '\') instead.'
557            );
558        }
559
560        // A list with a comparison operator is a declaration error — lists
561        // belong to whereIn()/whereNotIn() or whereBetween()/whereNotBetween(),
562        // which own the arity and homogeneity validation. Reaching here with
563        // a list would bind the array wholesale (the bind guard rejects it
564        // with a driver-level error instead of a declaration-level one).
565        if (is_array($value)) {
566            $label = is_string($column) ? $column : $column->value;
567            throw new \InvalidArgumentException(
568                "where('{$label}', '{$operator->value}', list) — list values belong to "
569                . 'whereIn()/whereNotIn() or whereBetween()/whereNotBetween().'
570            );
571        }
572
573        $clone = clone $this;
574        $clone->wheres[] = ['type' => WhereType::Basic, 'column' => $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean];
575        if (!$value instanceof Expression && !$value instanceof ToSqlValue) {
576            $clone->bindings[BindingCategory::Where->value][] = $value;
577        }
578        return $clone;
579    }
580
581    /**
582     * Add a raw where clause to the query.
583     *
584     * Keep user input out of the `$sql` string itself — put it in
585     * `$bindings`.
586     *
587     * @param  string  $sql
588     * @param  array<int, mixed>  $bindings
589     * @param  WhereBoolean  $boolean
590     * @return static
591     */
592    public function whereRaw(string $sql, array $bindings = [], WhereBoolean $boolean = WhereBoolean::And): static
593    {
594        $clone = clone $this;
595        $clone->wheres[] = ['type' => WhereType::Raw, 'sql' => $sql, 'boolean' => $boolean];
596        array_push($clone->bindings[BindingCategory::Where->value], ...$bindings);
597        return $clone;
598    }
599
600    /**
601     * Add a where clause comparing two columns to the query.
602     *
603     * @param  string  $first
604     * @param  ColumnOperator|string  $operator
605     * @param  string  $second
606     * @param  WhereBoolean  $boolean
607     * @return static
608     *
609     * @throws \InvalidArgumentException
610     */
611    public function whereColumn(string $first, ColumnOperator|string $operator = '=', string $second = '', WhereBoolean $boolean = WhereBoolean::And): static
612    {
613        $resolved = $operator instanceof ColumnOperator ? $operator : ColumnOperator::fromChecked($operator);
614        $clone = clone $this;
615        $clone->wheres[] = ['type' => WhereType::Column, 'first' => $first, 'operator' => $resolved, 'second' => $second, 'boolean' => $boolean];
616        return $clone;
617    }
618
619    /**
620     * Add an `EXISTS (subquery)` clause to the query.
621     *
622     * The subquery is a caller-built builder, typically correlated to
623     * the outer query with `whereColumn()`. Its bindings are captured
624     * eagerly into the Where category, so compiling stays a snapshot.
625     *
626     * @param  QueryBuilder  $query  The existential subquery.
627     * @param  WhereBoolean  $boolean
628     * @param  bool  $negated  True renders `NOT EXISTS`.
629     * @return static
630     */
631    public function whereExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static
632    {
633        $clone = clone $this;
634        $clone->wheres[] = ['type' => WhereType::Exists, 'query' => $query, 'negated' => $negated, 'boolean' => $boolean];
635        array_push($clone->bindings[BindingCategory::Where->value], ...$query->getBindings());
636        return $clone;
637    }
638
639    /**
640     * Add a `NOT EXISTS (subquery)` clause to the query.
641     *
642     * @param  QueryBuilder  $query  The existential subquery.
643     * @param  WhereBoolean  $boolean
644     * @return static
645     */
646    public function whereNotExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static
647    {
648        return $this->whereExists($query, $boolean, true);
649    }
650
651    /**
652     * Add an OR-connected `EXISTS (subquery)` clause to the query.
653     *
654     * @param  QueryBuilder  $query  The existential subquery.
655     * @return static
656     */
657    public function orWhereExists(QueryBuilder $query): static
658    {
659        return $this->whereExists($query, WhereBoolean::Or);
660    }
661
662    /**
663     * Add an OR-connected `NOT EXISTS (subquery)` clause to the query.
664     *
665     * @param  QueryBuilder  $query  The existential subquery.
666     * @return static
667     */
668    public function orWhereNotExists(QueryBuilder $query): static
669    {
670        return $this->whereExists($query, WhereBoolean::Or, true);
671    }
672
673    /**
674     * Add a `column IN (subquery)` clause to the query.
675     *
676     * The subquery must select exactly one column; its bindings are
677     * captured eagerly into the Where category, like `whereExists()`.
678     *
679     * @param  string  $column  The outer column the IN constrains.
680     * @param  QueryBuilder  $query  The single-column value subquery.
681     * @param  WhereBoolean  $boolean
682     * @param  bool  $negated  True renders `NOT IN`.
683     * @return static
684     */
685    public function whereInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static
686    {
687        $clone = clone $this;
688        $clone->wheres[] = ['type' => WhereType::InSub, 'column' => $column, 'query' => $query, 'negated' => $negated, 'boolean' => $boolean];
689        array_push($clone->bindings[BindingCategory::Where->value], ...$query->getBindings());
690        return $clone;
691    }
692
693    /**
694     * Add a `column NOT IN (subquery)` clause to the query.
695     *
696     * @param  string  $column  The outer column the NOT IN constrains.
697     * @param  QueryBuilder  $query  The single-column value subquery.
698     * @param  WhereBoolean  $boolean
699     * @return static
700     */
701    public function whereNotInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static
702    {
703        return $this->whereInQuery($column, $query, $boolean, true);
704    }
705
706    /**
707     * Add an OR-connected `column IN (subquery)` clause to the query.
708     *
709     * @param  string  $column  The outer column the IN constrains.
710     * @param  QueryBuilder  $query  The single-column value subquery.
711     * @return static
712     */
713    public function orWhereInQuery(string $column, QueryBuilder $query): static
714    {
715        return $this->whereInQuery($column, $query, WhereBoolean::Or);
716    }
717
718    /**
719     * Add an OR-connected `column NOT IN (subquery)` clause to the query.
720     *
721     * @param  string  $column  The outer column the NOT IN constrains.
722     * @param  QueryBuilder  $query  The single-column value subquery.
723     * @return static
724     */
725    public function orWhereNotInQuery(string $column, QueryBuilder $query): static
726    {
727        return $this->whereInQuery($column, $query, WhereBoolean::Or, true);
728    }
729
730    /**
731     * Add a nested group of where clauses to the query.
732     *
733     * The callback receives a {@see WhereBuilder} — the where-family only —
734     * and must return it:
735     *
736     *     ->whereNested(fn (WhereBuilder $q) => $q->where('active', '=', 1))
737     *
738     * @param  callable(WhereBuilder): WhereBuilder  $callback
739     * @param  WhereBoolean  $boolean
740     * @return static
741     *
742     * @throws \InvalidArgumentException
743     */
744    final public function whereNested(callable $callback, WhereBoolean $boolean = WhereBoolean::And): static
745    {
746        $nested = $this->newNestedBuilder();
747        $group = $callback(new WhereBuilder($nested));
748
749        // Runtime boundary: the callable signature is PHPDoc-only, so a
750        // mutation-style callback (mutates the argument, returns nothing)
751        // hands back NULL here. PHPStan cannot see this — it trusts the
752        // declared signature and would flag the instanceof as always-true —
753        // but at runtime it is the difference between a clear declaration
754        // error and a bare "call to a member function on null". The ignore
755        // is scoped and justified: the check is redundant FOR TYPED
756        // CALLERS, which is exactly who PHPStan analyzes.
757        /** @phpstan-ignore instanceof.alwaysTrue (runtime boundary: untyped callbacks may return null — see the project convention on scoped ignores) */
758        if (!$group instanceof WhereBuilder) {
759            throw new \InvalidArgumentException(
760                'whereNested() callback must RETURN the WhereBuilder it received '
761                . '(the builder is immutable — mutating the argument without returning it adds nothing).'
762            );
763        }
764
765        $groupQuery = $group->getNestedQuery();
766
767        // An empty group is a declaration bug, not a neutral filter: on SQL
768        // it compiles to degenerate `()` SQL, and evaluators that walk the
769        // clause list would read past its end. Fail fast at declaration.
770        if ($groupQuery->getWheres() === []) {
771            throw new \InvalidArgumentException(
772                'A nested where group must contain at least one clause; the callback added none.'
773            );
774        }
775
776        $clone = clone $this;
777        $clone->wheres[] = ['type' => WhereType::Nested, 'group' => new WhereGroup($groupQuery->getWheres()), 'boolean' => $boolean];
778        array_push($clone->bindings[BindingCategory::Where->value], ...$groupQuery->getBindings([BindingCategory::Where]));
779        return $clone;
780    }
781
782    /**
783     * Get the builder a nested where group stores its clauses on.
784     *
785     * @return self
786     */
787    protected function newNestedBuilder(): self
788    {
789        return new self($this->connection, $this->table);
790    }
791
792    // ---- Grouping / Having ----
793
794    /**
795     * Add a group by clause to the query.
796     *
797     * @param  string|array<int, string>  $columns
798     * @return static
799     */
800    public function groupBy(string|array $columns): static
801    {
802        $clone = clone $this;
803        $clone->groups = array_merge($this->groups, is_array($columns) ? $columns : func_get_args());
804        return $clone;
805    }
806
807    /**
808     * Add a having clause to the query.
809     *
810     * @param  string|Expression|Aggregate  $column
811     * @param  WhereOperator|string  $operator
812     * @param  mixed  $value
813     * @return static
814     */
815    public function having(string|Expression|Aggregate $column, WhereOperator|string $operator, mixed $value): static
816    {
817        $operator = $operator instanceof WhereOperator ? $operator : WhereOperator::fromChecked($operator);
818        $clone = clone $this;
819        $clone->havings[] = ['type' => WhereType::Basic, 'column' => $column, 'operator' => $operator, 'value' => $value];
820        if ($value !== null && !$value instanceof Expression && !$value instanceof ToSqlValue) {
821            $clone->bindings[BindingCategory::Having->value][] = $value;
822        }
823        return $clone;
824    }
825
826    // ---- Ordering / Limit / Offset ----
827
828    /**
829     * Add an order by clause to the query.
830     *
831     * @param  string|Expression  $column
832     * @param  SortDirection|string  $direction
833     * @return static
834     *
835     * @throws \InvalidArgumentException
836     */
837    public function orderBy(string|Expression $column, SortDirection|string $direction = SortDirection::Asc): static
838    {
839        $normalized = $direction instanceof SortDirection
840            ? $direction
841            : SortDirection::fromChecked($direction);
842        $clone = clone $this;
843        $clone->orders[] = ['column' => $column, 'direction' => $normalized];
844        return $clone;
845    }
846
847    /**
848     * Set the "limit" value of the query.
849     *
850     * @param  int  $limit
851     * @return static
852     */
853    public function limit(int $limit): static
854    {
855        $clone = clone $this;
856        $clone->limit = $limit;
857        return $clone;
858    }
859
860    /**
861     * Set the "offset" value of the query.
862     *
863     * @param  int  $offset
864     * @return static
865     */
866    public function offset(int $offset): static
867    {
868        $clone = clone $this;
869        $clone->offset = $offset;
870        return $clone;
871    }
872
873    // ---- Unions ----
874
875    /**
876     * Add a union to the query.
877     *
878     * @param  QueryBuilder  $query
879     * @param  bool  $all  Whether to use "UNION ALL".
880     * @return static
881     */
882    final public function union(QueryBuilder $query, bool $all = false): static
883    {
884        $clone = clone $this;
885        $clone->unions[] = ['query' => $query, 'all' => $all];
886        // Eager capture: APPEND, because unions are a SEQUENCE — each
887        // union() call adds its sub-builder's bindings after the previous
888        // ones, matching the compiled order of the UNION clauses.
889        array_push($clone->bindings[BindingCategory::Union->value], ...$query->getBindings());
890        return $clone;
891    }
892
893    // ---- Locks ----
894
895    /**
896     * Lock the selected rows for update.
897     *
898     * @return static
899     */
900    final public function lockForUpdate(): static
901    {
902        $clone = clone $this;
903        $clone->lock = LockType::Update;
904        return $clone;
905    }
906
907    /**
908     * Lock the selected rows in shared mode.
909     *
910     * @return static
911     */
912    final public function sharedLock(): static
913    {
914        $clone = clone $this;
915        $clone->lock = LockType::Shared;
916        return $clone;
917    }
918
919    // ---- Execution ----
920
921    /**
922     * Run the query and return the matching rows.
923     *
924     * @return Collection<int, \stdClass>
925     */
926    public function get(): Collection
927    {
928        return $this->connection->select($this);
929    }
930
931    /**
932     * Run the query and yield each matching row as it arrives.
933     *
934     * Consume the generator fully (or let it be garbage collected) before
935     * running another query on the connection.
936     *
937     * @return \Generator<int, \stdClass>
938     */
939    public function cursor(): \Generator
940    {
941        return $this->connection->cursor($this);
942    }
943
944    /**
945     * Run the query and return the first matching row.
946     *
947     * @return \stdClass|null
948     */
949    public function first(): ?object
950    {
951        return $this->limit(1)->get()->first();
952    }
953
954    /**
955     * The value of a single column from the first row.
956     *
957     * The column is selected under a stable alias so the result can be read
958     * back by name regardless of how the dialect names the raw expression.
959     *
960     * @param  string|Aggregate  $column
961     * @return mixed
962     */
963    public function value(string|Aggregate $column): mixed
964    {
965        if ($column instanceof Aggregate) {
966            // The aggregate rides the stable `radiant_scalar` alias; the
967            // row fetch reads it back by name. select() is immutable — it
968            // returns a new builder, leaving this one's columns untouched.
969            return $this
970                ->select(new Aggregate($column->function, $column->column, 'radiant_scalar'))
971                ->first()
972                ->radiant_scalar ?? null;
973        }
974
975        // The columnar fetch: the connection reads the single column
976        // directly (no per-row object), positionally — no alias read-back.
977        // select() is immutable — the scoped select leaves this builder
978        // untouched, so no explicit clone is needed.
979        return $this->connection->selectColumn($this->select($this->scalarColumn($column))->limit(1))->first();
980    }
981
982    /**
983     * A collection of a single column's values from all rows.
984     *
985     * @param  string  $column
986     * @return Collection<int, mixed>
987     */
988    public function pluck(string $column): Collection
989    {
990        // The columnar fetch: values come back positionally, one per row —
991        // no per-row object materialized, no alias read per row. select()
992        // is immutable — the scoped select leaves this builder untouched.
993        return $this->connection->selectColumn($this->select($this->scalarColumn($column)));
994    }
995
996    /**
997     * Resolve a column into the SQL to select.
998     *
999     * A trailing `as alias` is stripped — the scalar reads fetch the column
1000     * positionally, so the result header is never read by name.
1001     *
1002     * @param  string  $column
1003     * @return string
1004     */
1005    protected function scalarColumn(string $column): string
1006    {
1007        return (string) preg_replace('/\s+as\s+[`"]?[a-z_][a-z0-9_]*[`"]?$/i', '', $column);
1008    }
1009
1010    // ---- Aggregates are just select fields (built on select()) ----
1011
1012    /**
1013     * Count the matching rows.
1014     *
1015     * @return int
1016     */
1017    public function count(): int
1018    {
1019        return (int) $this->value(Aggregate::count());
1020    }
1021
1022    /**
1023     * Whether any matching rows exist.
1024     *
1025     * A limit-1 probe rather than a COUNT: the backend stops at the first
1026     * matching row instead of counting every one.
1027     *
1028     * @return bool
1029     */
1030    final public function exists(): bool
1031    {
1032        return $this->first() !== null;
1033    }
1034
1035    /**
1036     * The maximum value of a column.
1037     *
1038     * @param  string  $column
1039     * @return mixed
1040     */
1041    public function max(string $column): mixed
1042    {
1043        return $this->value(Aggregate::max($column));
1044    }
1045
1046    /**
1047     * The minimum value of a column.
1048     *
1049     * @param  string  $column
1050     * @return mixed
1051     */
1052    public function min(string $column): mixed
1053    {
1054        return $this->value(Aggregate::min($column));
1055    }
1056
1057    /**
1058     * The sum of a column's values.
1059     *
1060     * @param  string  $column
1061     * @return mixed
1062     */
1063    public function sum(string $column): mixed
1064    {
1065        return $this->value(Aggregate::sum($column));
1066    }
1067
1068    /**
1069     * The average of a column's values.
1070     *
1071     * @param  string  $column
1072     * @return mixed
1073     */
1074    public function avg(string $column): mixed
1075    {
1076        return $this->value(Aggregate::avg($column));
1077    }
1078
1079    /**
1080     * Multiple aggregates in one query.
1081     *
1082     * The aggregate's own alias names its result column:
1083     *
1084     *     $db->table('orders')->aggregates(
1085     *         Aggregate::count('*', 'total'),
1086     *         Aggregate::max('price', 'top'),
1087     *     );
1088     *
1089     * @param  Aggregate  ...$aggregates
1090     * @return \stdClass
1091     */
1092    public function aggregates(Aggregate ...$aggregates): \stdClass
1093    {
1094        // The aggregate select rides select() — immutable, so this builder's
1095        // own column list is untouched.
1096        return $this->select(...$aggregates)->first()
1097            ?? throw new \LogicException(
1098                'aggregates() cannot run — the query matched no rows to aggregate (this '
1099                . 'indicates a connection that returned an empty first() without an aggregate row).'
1100            );
1101    }
1102
1103    /**
1104     * Run one aggregate per group of the matching rows — a grouped
1105     * aggregate in a single query.
1106     *
1107     * The result is keyed by the group column's value, so the aggregate's
1108     * own alias is ignored here (it matters only for the multi-aggregate
1109     * row shape of aggregates()). A raw table has no casts, so values come
1110     * back as the driver delivered them — except `count`, which is always
1111     * an int.
1112     *
1113     * @param  Aggregate  $aggregate  The aggregate to compute per group.
1114     * @param  string  $groupBy  The column whose values key the result.
1115     * @return Collection<string, mixed>
1116     */
1117    public function aggregateBy(Aggregate $aggregate, string $groupBy): Collection
1118    {
1119        $rows = $this->select($groupBy, new Aggregate($aggregate->function, $aggregate->column, self::AGGREGATE_ALIAS))
1120            ->groupBy($groupBy)
1121            ->get();
1122
1123        $out = [];
1124
1125        foreach ($rows as $row) {
1126            $key = (string) $row->{$groupBy};
1127            $raw = $row->{self::AGGREGATE_ALIAS};
1128
1129            $out[$key] = $aggregate->function === 'count'
1130                ? (int) $raw
1131                : $raw;
1132        }
1133
1134        return Collection::make($out);
1135    }
1136
1137    /**
1138     * Count the matching rows per group of a column — in a single query.
1139     *
1140     * The result is keyed by the group column's value with int counts.
1141     *
1142     * The optional seed lists group values that must appear even when the
1143     * database has no rows for them — each seeded key absent from the
1144     * result becomes 0. The seed is ADDITIVE: database rows always win,
1145     * and group values found in the data but missing from the seed still
1146     * appear. (Only counts can be seeded — an absent group has no honest
1147     * min, max, or average.)
1148     *
1149     * @param  string  $column  The column whose values key the result.
1150     * @param  list<int|string>|null  $seed  Group values guaranteed to appear (0 when absent).
1151     * @return Collection<string, int>
1152     */
1153    public function countBy(string $column, ?array $seed = null): Collection
1154    {
1155        /** @var Collection<string, int> $counts */
1156        $counts = $this->aggregateBy(Aggregate::count('*'), $column);
1157
1158        if ($seed !== null) {
1159            $out = $counts->all();
1160
1161            foreach ($seed as $value) {
1162                $key = (string) $value;
1163                $out[$key] ??= 0;
1164            }
1165
1166            return Collection::make($out);
1167        }
1168
1169        return $counts;
1170    }
1171
1172    // ---- Writes ----
1173
1174    /**
1175     * Insert one or more rows.
1176     *
1177     * @param  array<string, mixed>|list<array<string, mixed>>  $values
1178     * @return int
1179     */
1180    public function insert(array $values): int
1181    {
1182        return $this->connection->insert($this, $values);
1183    }
1184
1185    /**
1186     * Insert a single row and return the generated id.
1187     *
1188     * @param  array<string, mixed>  $values
1189     * @return string|int|null
1190     */
1191    public function insertGetId(array $values): string|int|null
1192    {
1193        return $this->connection->insertGetId($this, $values);
1194    }
1195
1196    /**
1197     * Update the rows matching the query's conditions.
1198     *
1199     * @param  array<string, mixed>  $values
1200     * @return int
1201     */
1202    public function update(array $values): int
1203    {
1204        return $this->connection->update($this, $values);
1205    }
1206
1207    /**
1208     * Delete the rows matching the query's conditions.
1209     *
1210     * @return int
1211     */
1212    final public function delete(): int
1213    {
1214        return $this->connection->delete($this);
1215    }
1216
1217    // ---- Bindings ----
1218
1219    /**
1220     * Get the flattened bindings for the given categories, in canonical order.
1221     *
1222     * @param  list<BindingCategory>|null  $categories
1223     * @return list<BindingValue>
1224     */
1225    final public function getBindings(?array $categories = null): array
1226    {
1227        $categories ??= array_keys($this->bindings);
1228        $bindings = [];
1229        foreach ($categories as $category) {
1230            $key = $category instanceof BindingCategory ? $category->value : $category;
1231            array_push($bindings, ...$this->bindings[$key]);
1232        }
1233        return $bindings;
1234    }
1235
1236    /**
1237     * Declare the primary key column so insertGetId() can return it.
1238     *
1239     * @param  string  $column
1240     * @param  bool  $autoIncrement
1241     * @return static
1242     */
1243    final public function insertIdColumn(string $column, bool $autoIncrement = true): static
1244    {
1245        $clone = clone $this;
1246        $clone->insertIdColumn = $column;
1247        $clone->insertIdAutoIncrement = $autoIncrement;
1248        return $clone;
1249    }
1250
1251    // ---- Accessors: the query state contract ----
1252
1253    /*
1254     * These getters ARE the public contract for every consumer of a built
1255     * query: the SQL {@see Grammar} compiles them to text, and custom
1256     * `ConnectionInterface` implementations (e.g. CSV) execute them directly
1257     * in PHP. Shapes are defined here and imported elsewhere via
1258     * `@phpstan-import-type`; they must not drift between consumers.
1259     */
1260
1261    /**
1262     * Assert the query uses ONLY the given features, or fail fast.
1263     *
1264     * @param  SqlFeature  ...$features
1265     * @return static
1266     * @throws \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException
1267     */
1268    final public function assertSupports(SqlFeature ...$features): static
1269    {
1270        $used = SqlFeature::usedBy($this);
1271        $violated = array_values(array_filter(
1272            $used,
1273            fn(SqlFeature $feature) => !in_array($feature, $features, true),
1274        ));
1275
1276        if ($violated !== []) {
1277            $names = implode(', ', array_map(fn(SqlFeature $f) => $f->value, $violated));
1278            throw new \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException(
1279                "This query uses feature(s) [{$names}] outside the supported set."
1280            );
1281        }
1282
1283        return $this;
1284    }
1285
1286    /**
1287     * The columns to select.
1288     *
1289     * @return list<string|Expression|Aggregate|SubquerySelect>
1290     */
1291    final public function getColumns(): array
1292    {
1293        return $this->columns;
1294    }
1295
1296    /**
1297     * Whether the select is distinct.
1298     *
1299     * @return bool
1300     */
1301    final public function isDistinct(): bool
1302    {
1303        return $this->distinct;
1304    }
1305
1306    /**
1307     * The from clause — a table name or a subquery builder.
1308     *
1309     * @return string|QueryBuilder
1310     */
1311    final public function getFrom(): string|QueryBuilder
1312    {
1313        return $this->from;
1314    }
1315
1316    /**
1317     * The alias of the from subquery, when `fromSub()` was used.
1318     *
1319     * @return string|null
1320     */
1321    final public function getFromAlias(): ?string
1322    {
1323        return $this->fromAlias;
1324    }
1325
1326    /**
1327     * The joins to apply.
1328     *
1329     * @return list<array{type: JoinType, table: string, wheres: list<array{type: WhereType::Column, first: string, operator: ColumnOperator, second: string, boolean: WhereBoolean}>}>
1330     */
1331    final public function getJoins(): array
1332    {
1333        return $this->joins;
1334    }
1335
1336    /**
1337     * The where clauses.
1338     *
1339     * A discriminated union keyed by {@see WhereType} — exhaustively match
1340     * on `type` to handle every shape.
1341     *
1342     * @return list<WhereClause>
1343     */
1344    final public function getWheres(): array
1345    {
1346        return $this->wheres;
1347    }
1348
1349    /**
1350     * Mark the most recent where clause as a trait-declared scope clause.
1351     *
1352     * @param  string  $trait  The declaring trait's class-string.
1353     * @return void
1354     * @throws \LogicException
1355     */
1356    protected function markLastWhereTraitScope(string $trait): void
1357    {
1358        if ($this->wheres === []) {
1359            throw new \LogicException('Cannot mark the trait scope: the builder has no where clauses.');
1360        }
1361
1362        $last = count($this->wheres) - 1;
1363        /** @phpstan-ignore assign.propertyType (the marker key is only meaningful on the clause arms that carry scopes; the union shape lists it per-arm) */
1364        $this->wheres[$last]['traitScope'] = $trait;
1365    }
1366
1367    /**
1368     * The group-by columns.
1369     *
1370     * @return list<string>
1371     */
1372    final public function getGroups(): array
1373    {
1374        return $this->groups;
1375    }
1376
1377    /**
1378     * The having clauses.
1379     *
1380     * @return list<array{type: WhereType::Basic, column: string|Expression|Aggregate, operator: WhereOperator, value: mixed}>
1381     */
1382    final public function getHavings(): array
1383    {
1384        return $this->havings;
1385    }
1386
1387    /**
1388     * The order-by clauses.
1389     *
1390     * @return list<array{column: string|Expression, direction: SortDirection|null}>
1391     */
1392    final public function getOrders(): array
1393    {
1394        return $this->orders;
1395    }
1396
1397    /**
1398     * The unions to append.
1399     *
1400     * @return list<array{query: QueryBuilder, all: bool}>
1401     */
1402    final public function getUnions(): array
1403    {
1404        return $this->unions;
1405    }
1406
1407    /**
1408     * The row lock to apply, or null for none.
1409     *
1410     * @return LockType|null
1411     */
1412    final public function getLock(): ?LockType
1413    {
1414        return $this->lock;
1415    }
1416
1417    /**
1418     * The maximum number of rows to return.
1419     *
1420     * @return int|null
1421     */
1422    final public function getLimit(): ?int
1423    {
1424        return $this->limit;
1425    }
1426
1427    /**
1428     * The number of rows to skip.
1429     *
1430     * @return int|null
1431     */
1432    final public function getOffset(): ?int
1433    {
1434        return $this->offset;
1435    }
1436
1437    /**
1438     * The PK column to return on insert, if known.
1439     *
1440     * @return string|null
1441     */
1442    final public function getInsertIdColumn(): ?string
1443    {
1444        return $this->insertIdColumn;
1445    }
1446
1447    /**
1448     * Whether the declared insert-id column is auto-increment.
1449     *
1450     * @return bool
1451     */
1452    final public function isInsertIdAutoIncrement(): bool
1453    {
1454        return $this->insertIdAutoIncrement ?? false;
1455    }
1456}

From BlueprintAU\Radiant\Concerns\FiltersWhere

28trait FiltersWhere
29{
30    /**
31     * Add a where clause — the single sink every other filter funnels into.
32     *
33     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
34     * @param  WhereOperator|string  $operator
35     * @param  mixed  $value
36     * @param  WhereBoolean  $boolean
37     * @return static
38     */
39    abstract public function where(
40        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
41        WhereOperator|string $operator,
42        mixed $value,
43        WhereBoolean $boolean = WhereBoolean::And,
44    ): static;
45
46    /**
47     * Add an equality where clause — sugar for
48     * `where($column, '=', $value)`.
49     *
50     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
51     * @param  mixed  $value
52     * @param  WhereBoolean  $boolean
53     * @return static
54     */
55    public function whereEq(
56        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
57        mixed $value,
58        WhereBoolean $boolean = WhereBoolean::And,
59    ): static {
60        return $this->where($column, WhereOperator::Eq, $value, $boolean);
61    }
62
63    /**
64     * Add an OR-connected equality where clause.
65     *
66     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
67     * @param  mixed  $value
68     * @return static
69     */
70    public function orWhereEq(
71        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
72        mixed $value,
73    ): static {
74        return $this->where($column, WhereOperator::Eq, $value, WhereBoolean::Or);
75    }
76
77    /**
78     * Add a nested where group — the second sink.
79     *
80     * @param  callable(WhereBuilder): WhereBuilder  $callback
81     * @param  WhereBoolean  $boolean
82     * @return static
83     */
84    abstract public function whereNested(
85        callable $callback,
86        WhereBoolean $boolean = WhereBoolean::And,
87    ): static;
88
89    /**
90     * Add a nested where group connected by AND.
91     *
92     * @param  callable(WhereBuilder): WhereBuilder  $callback
93     * @return static
94     */
95    public function whereNestedGroup(callable $callback): static
96    {
97        return $this->whereNested($callback, WhereBoolean::And);
98    }
99
100    /**
101     * Add an OR-connected nested where group.
102     *
103     * @param  callable(WhereBuilder): WhereBuilder  $callback
104     * @return static
105     */
106    public function orWhereNested(callable $callback): static
107    {
108        return $this->whereNested($callback, WhereBoolean::Or);
109    }
110
111    /**
112     * Add an `EXISTS (subquery)` clause to the query.
113     *
114     * The subquery is a caller-built builder, typically another model's
115     * `newQuery()` correlated to the outer query via `whereColumn()`.
116     *
117     * @param  QueryBuilder  $query  The existential subquery.
118     * @param  WhereBoolean  $boolean
119     * @param  bool  $negated  True renders `NOT EXISTS`.
120     * @return static
121     */
122    abstract public function whereExists(
123        QueryBuilder $query,
124        WhereBoolean $boolean = WhereBoolean::And,
125        bool $negated = false,
126    ): static;
127
128    /**
129     * Add a `NOT EXISTS (subquery)` clause.
130     *
131     * @param  QueryBuilder  $query  The existential subquery.
132     * @param  WhereBoolean  $boolean
133     * @return static
134     */
135    public function whereNotExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static
136    {
137        return $this->whereExists($query, $boolean, true);
138    }
139
140    /**
141     * Add an OR-connected `EXISTS (subquery)` clause.
142     *
143     * @param  QueryBuilder  $query  The existential subquery.
144     * @return static
145     */
146    public function orWhereExists(QueryBuilder $query): static
147    {
148        return $this->whereExists($query, WhereBoolean::Or);
149    }
150
151    /**
152     * Add an OR-connected `NOT EXISTS (subquery)` clause.
153     *
154     * @param  QueryBuilder  $query  The existential subquery.
155     * @return static
156     */
157    public function orWhereNotExists(QueryBuilder $query): static
158    {
159        return $this->whereExists($query, WhereBoolean::Or, true);
160    }
161
162    /**
163     * Add a `column IN (subquery)` clause to the query.
164     *
165     * The subquery must select exactly one column.
166     *
167     * @param  string  $column  The outer column the IN constrains.
168     * @param  QueryBuilder  $query  The single-column value subquery.
169     * @param  WhereBoolean  $boolean
170     * @param  bool  $negated  True renders `NOT IN`.
171     * @return static
172     */
173    abstract public function whereInQuery(
174        string $column,
175        QueryBuilder $query,
176        WhereBoolean $boolean = WhereBoolean::And,
177        bool $negated = false,
178    ): static;
179
180    /**
181     * Add a `column NOT IN (subquery)` clause.
182     *
183     * @param  string  $column  The outer column the NOT IN constrains.
184     * @param  QueryBuilder  $query  The single-column value subquery.
185     * @param  WhereBoolean  $boolean
186     * @return static
187     */
188    public function whereNotInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static
189    {
190        return $this->whereInQuery($column, $query, $boolean, true);
191    }
192
193    /**
194     * Add an OR-connected `column IN (subquery)` clause.
195     *
196     * @param  string  $column  The outer column the IN constrains.
197     * @param  QueryBuilder  $query  The single-column value subquery.
198     * @return static
199     */
200    public function orWhereInQuery(string $column, QueryBuilder $query): static
201    {
202        return $this->whereInQuery($column, $query, WhereBoolean::Or);
203    }
204
205    /**
206     * Add an OR-connected `column NOT IN (subquery)` clause.
207     *
208     * @param  string  $column  The outer column the NOT IN constrains.
209     * @param  QueryBuilder  $query  The single-column value subquery.
210     * @return static
211     */
212    public function orWhereNotInQuery(string $column, QueryBuilder $query): static
213    {
214        return $this->whereInQuery($column, $query, WhereBoolean::Or, true);
215    }
216
217    /**
218     * Add an `or where` clause.
219     *
220     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
221     * @param  WhereOperator|string  $operator
222     * @param  mixed  $value
223     * @return static
224     */
225    public function orWhere(
226        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
227        WhereOperator|string $operator,
228        mixed $value,
229    ): static {
230        return $this->where($column, $operator, $value, WhereBoolean::Or);
231    }
232
233    /**
234     * Add a `where in` clause.
235     *
236     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
237     * @param  array<int, mixed>  $values
238     * @param  WhereBoolean  $boolean
239     * @return static
240     */
241    public function whereIn(
242        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
243        array $values,
244        WhereBoolean $boolean = WhereBoolean::And,
245    ): static {
246        return $this->where($column, WhereOperator::In, $values, $boolean);
247    }
248
249    /**
250     * Add a `where not in` clause.
251     *
252     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
253     * @param  array<int, mixed>  $values
254     * @param  WhereBoolean  $boolean
255     * @return static
256     */
257    public function whereNotIn(
258        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
259        array $values,
260        WhereBoolean $boolean = WhereBoolean::And,
261    ): static {
262        return $this->where($column, WhereOperator::NotIn, $values, $boolean);
263    }
264
265    /**
266     * Add a `where null` clause.
267     *
268     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
269     * @param  WhereBoolean  $boolean
270     * @return static
271     */
272    public function whereNull(string|\BlueprintAU\Radiant\Database\Query\Expression $column, WhereBoolean $boolean = WhereBoolean::And): static
273    {
274        return $this->where($column, WhereOperator::Null, null, $boolean);
275    }
276
277    /**
278     * Add a `where not null` clause.
279     *
280     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
281     * @param  WhereBoolean  $boolean
282     * @return static
283     */
284    public function whereNotNull(string|\BlueprintAU\Radiant\Database\Query\Expression $column, WhereBoolean $boolean = WhereBoolean::And): static
285    {
286        return $this->where($column, WhereOperator::NotNull, null, $boolean);
287    }
288
289    /**
290     * Add a `where between` clause.
291     *
292     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
293     * @param  array{0: mixed, 1: mixed}  $range
294     * @param  WhereBoolean  $boolean
295     * @return static
296     */
297    public function whereBetween(string|\BlueprintAU\Radiant\Database\Query\Expression $column, array $range, WhereBoolean $boolean = WhereBoolean::And): static
298    {
299        return $this->where($column, WhereOperator::Between, $range, $boolean);
300    }
301
302    /**
303     * Add a `where not between` clause.
304     *
305     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
306     * @param  array{0: mixed, 1: mixed}  $range
307     * @param  WhereBoolean  $boolean
308     * @return static
309     */
310    public function whereNotBetween(string|\BlueprintAU\Radiant\Database\Query\Expression $column, array $range, WhereBoolean $boolean = WhereBoolean::And): static
311    {
312        return $this->where($column, WhereOperator::NotBetween, $range, $boolean);
313    }
314
315    /**
316     * Add a `where like` clause — the pattern is a bound value.
317     *
318     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
319     * @param  string  $pattern
320     * @param  WhereBoolean  $boolean
321     * @return static
322     */
323    public function whereLike(string|\BlueprintAU\Radiant\Database\Query\Expression $column, string $pattern, WhereBoolean $boolean = WhereBoolean::And): static
324    {
325        return $this->where($column, WhereOperator::Like, $pattern, $boolean);
326    }
327
328    /**
329     * Add an OR-connected `where like` clause.
330     *
331     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
332     * @param  string  $pattern
333     * @return static
334     */
335    public function orWhereLike(string|\BlueprintAU\Radiant\Database\Query\Expression $column, string $pattern): static
336    {
337        return $this->where($column, WhereOperator::Like, $pattern, WhereBoolean::Or);
338    }
339
340    /**
341     * Add a `where not like` clause.
342     *
343     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
344     * @param  string  $pattern
345     * @param  WhereBoolean  $boolean
346     * @return static
347     */
348    public function whereNotLike(string|\BlueprintAU\Radiant\Database\Query\Expression $column, string $pattern, WhereBoolean $boolean = WhereBoolean::And): static
349    {
350        return $this->where($column, WhereOperator::NotLike, $pattern, $boolean);
351    }
352}