Lines 97.50% 782 / 802
Methods 92.24% 107 / 116
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 clearRelationCache 100.00% 6 / 6 100.00% 1 / 1 4
 __construct 100.00% 46 / 46 100.00% 1 / 1 12
 with 100.00% 7 / 7 100.00% 1 / 1 3
 assertRelationPath 100.00% 4 / 4 100.00% 1 / 1 2
 validateRelationPath 100.00% 7 / 7 100.00% 1 / 1 3
 resolveRelation 100.00% 19 / 19 100.00% 1 / 1 5
 eagerLoadRelations 100.00% 2 / 2 100.00% 1 / 1 2
 loadRelationPath 100.00% 7 / 7 100.00% 1 / 1 2
 splitPath 100.00% 4 / 4 100.00% 1 / 1 2
 loadRelation 100.00% 41 / 41 100.00% 1 / 1 14
 buildPartitions 100.00% 4 / 4 100.00% 1 / 1 2
 applyMtiJoins 100.00% 26 / 26 100.00% 1 / 1 4
 whereColumn 100.00% 3 / 3 100.00% 1 / 1 1
 whereRaw 100.00% 1 / 1 100.00% 1 / 1 1
 whereExists 100.00% 1 / 1 100.00% 1 / 1 1
 whereInQuery 100.00% 2 / 2 100.00% 1 / 1 1
 correlateWith 55.55% 5 / 9 0.00% 0 / 1 3.79
 newNestedBuilder 100.00% 3 / 3 100.00% 1 / 1 1
 addJoin 100.00% 3 / 3 100.00% 1 / 1 1
 qualifyColumns 100.00% 9 / 9 100.00% 1 / 1 6
 on 100.00% 3 / 3 100.00% 1 / 1 1
 orOn 100.00% 3 / 3 100.00% 1 / 1 1
 withTrashed 100.00% 1 / 1 100.00% 1 / 1 1
 onlyTrashed 100.00% 13 / 13 100.00% 1 / 1 3
 withoutScope 100.00% 10 / 10 100.00% 1 / 1 2
 withoutScopes 100.00% 10 / 10 100.00% 1 / 1 2
 get 100.00% 8 / 8 100.00% 1 / 1 1
 getRaw 100.00% 1 / 1 100.00% 1 / 1 1
 cursor 100.00% 2 / 2 100.00% 1 / 1 2
 first 100.00% 9 / 9 100.00% 1 / 1 3
 find 100.00% 1 / 1 100.00% 1 / 1 1
 firstOrFail 100.00% 1 / 1 100.00% 1 / 1 1
 findOrFail 100.00% 1 / 1 100.00% 1 / 1 1
 sole 100.00% 14 / 14 100.00% 1 / 1 5
 firstOrFailWithKey 100.00% 4 / 4 100.00% 1 / 1 2
 firstOrCreate 100.00% 4 / 4 100.00% 1 / 1 2
 findOrCreate 100.00% 4 / 4 100.00% 1 / 1 2
 createModel 91.66% 22 / 24 0.00% 0 / 1 10.06
 invertibleWhereFills 95.65% 22 / 23 0.00% 0 / 1 11
 keyFills 55.55% 10 / 18 0.00% 0 / 1 9.16
 value 100.00% 11 / 11 100.00% 1 / 1 5
 pluck 100.00% 4 / 4 100.00% 1 / 1 1
 scopedFor 100.00% 3 / 3 100.00% 1 / 1 2
 decodeScalar 100.00% 6 / 6 100.00% 1 / 1 2
 encodeWhereValue 100.00% 18 / 18 100.00% 1 / 1 13
 count 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% 11 / 11 100.00% 1 / 1 6
 aggregateBy 100.00% 11 / 11 100.00% 1 / 1 3
 countBy 100.00% 8 / 8 100.00% 1 / 1 3
 decodeAggregateColumn 100.00% 3 / 3 100.00% 1 / 1 3
 whereKey 98.33% 59 / 60 0.00% 0 / 1 19
 applyWhereKeyOn 100.00% 23 / 23 100.00% 1 / 1 11
 assertCompositeKeyValue 100.00% 11 / 11 100.00% 1 / 1 7
 assertCompositeKeyColumn 100.00% 8 / 8 100.00% 1 / 1 3
 select 100.00% 39 / 39 100.00% 1 / 1 14
 qualifyForJoin 100.00% 8 / 8 100.00% 1 / 1 4
 where 100.00% 15 / 15 100.00% 1 / 1 10
 orderBy 100.00% 5 / 5 100.00% 1 / 1 2
 groupBy 100.00% 3 / 3 100.00% 1 / 1 3
 having 100.00% 9 / 9 100.00% 1 / 1 5
 validateColumn 100.00% 28 / 28 100.00% 1 / 1 17
 insert 100.00% 3 / 3 100.00% 1 / 1 1
 insertGetId 100.00% 3 / 3 100.00% 1 / 1 1
 update 100.00% 2 / 2 100.00% 1 / 1 1
 normalizeRows 100.00% 3 / 3 100.00% 1 / 1 2
 encodeRows 100.00% 4 / 4 100.00% 1 / 1 2
 encodeRow 100.00% 5 / 5 100.00% 1 / 1 2
 encodeValue 100.00% 3 / 3 100.00% 1 / 1 1
 validateWriteColumn 100.00% 4 / 4 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] normalizeTableReference 100.00% 6 / 6 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] distinct 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] fromSub 100.00% 7 / 7 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] join 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] leftJoin 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] rightJoin 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] crossJoin 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] addOn 100.00% 15 / 15 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] assertHomogeneousList 100.00% 12 / 12 100.00% 1 / 1 6
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] whereNotExists 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereExists 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereNotExists 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] whereNotInQuery 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereNotInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] whereNested 100.00% 16 / 16 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] limit 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] offset 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] union 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] lockForUpdate 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] sharedLock 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] scalarColumn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] exists 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] delete 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getBindings 100.00% 6 / 6 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] insertIdColumn 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] assertSupports 100.00% 11 / 11 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getColumns 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] isDistinct 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getFrom 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getFromAlias 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getJoins 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getWheres 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] markLastWhereTraitScope 75.00% 3 / 4 0.00% 0 / 1 2.06
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getGroups 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getHavings 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getOrders 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getUnions 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getLock 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getLimit 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getOffset 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] getInsertIdColumn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Query\QueryBuilder] isInsertIdAutoIncrement 100.00% 1 / 1 100.00% 1 / 1 1
37final class ModelQueryBuilder extends QueryBuilder
38{
39    /**
40     * The columns always force-selected so hydration keeps the identity
41     * (PK) and trash-state (soft-delete) columns available â€” `whereKey()`
42     * needs the former, `trashed()` and save()'s soft-deleted guard the
43     * latter.
44     *
45     * @var list<string>
46     */
47    protected array $forcedKeys = [];
48
49    /**
50     * Every declared column name â€” the default select and the validation
51     * set for every column-accepting method.
52     *
53     * @var list<string>
54     */
55    protected array $modelColumns = [];
56
57    /**
58     * The eager-loaded relation paths (validated at with() time).
59     *
60     * @var list<string>
61     */
62    protected array $eagerLoad = [];
63
64    /**
65     * The column â†’ owning-table partition map (MTI only; empty otherwise).
66     *
67     * @var array<string, string>
68     */
69    protected array $partitions = [];
70
71    /**
72     * The MTI ancestor chain (nearest parent first), each as
73     * [class, table]. Empty for non-MTI models.
74     *
75     * @var list<array{class-string<Model>, string}>
76     */
77    protected array $mtiChain = [];
78
79    /**
80     * Memoized relation resolutions, keyed by "class::method".
81     *
82     * @var array<string, Relations\Relation<Model>>
83     */
84    protected static array $relationCache = [];
85
86    /**
87     * Invalidate the memoized relation-resolution cache.
88     *
89     * @param  string|null  $class  Clear only this class's relations; null clears everything.
90     * @return void
91     */
92    public static function clearRelationCache(?string $class = null): void
93    {
94        if ($class === null) {
95            static::$relationCache = [];
96            return;
97        }
98
99        foreach (array_keys(static::$relationCache) as $key) {
100            if (str_starts_with($key, $class . '::')) {
101                unset(static::$relationCache[$key]);
102            }
103        }
104    }
105
106    /**
107     * Declared-column hash set for {@see validateColumn()} (lazy).
108     *
109     * @var array<string, true>|null
110     */
111    protected ?array $columnSet = null;
112
113    /**
114     * Forced-PK hash set for {@see validateColumn()} (lazy).
115     *
116     * @var array<string, true>|null
117     */
118    protected ?array $forcedKeySet = null;
119
120    /**
121     * Whether this builder is a nested where group â€” nested builders skip
122     * trait-scope application (the scopes ride the OUTER builder; applying
123     * them here would recurse: whereNested â†’ newNestedBuilder â†’ ctor â†’
124     * whereNested).
125     *
126     * @var bool
127     */
128    private bool $nested = false;
129
130    /**
131     * Tables qualified column specs may reference beyond this builder's
132     * own â€” the correlation context stamped by {@see correlateWith()}.
133     * Validation-only: never compiled.
134     *
135     * @var list<string>
136     */
137    protected array $correlationTables = [];
138
139    /**
140     * Create a builder bound to a model class on a connection.
141     *
142     * @param  class-string<TModel>  $modelClass
143     * @param  ConnectionInterface  $connection
144     * @param  bool  $nested  Whether this builder is a nested where group (skips trait-scope application â€” the scopes ride the OUTER builder; applying them here would recurse).
145     */
146    public function __construct(
147        public readonly string $modelClass,
148        ConnectionInterface $connection,
149        bool $nested = false,
150    ) {
151        $this->nested = $nested;
152
153        $metadata = MetadataFactory::for($modelClass);
154
155        $this->forcedKeys = array_values(array_filter(array_map(
156            fn(Column $column) => $column->name ?? '',
157            $metadata->primaryKeys,
158        ), fn(string $name) => $name !== ''));
159
160        // The soft-delete column force-selects alongside the PKs: a narrow
161        // caller select that omits it hydrates models whose trash state is
162        // unknown â€” trashed() reads false and save()'s soft-deleted guard
163        // passes, letting an UPDATE under the auto-scope match 0 rows while
164        // reporting success.
165        if ($metadata->softDeleteColumn !== null
166            && !in_array($metadata->softDeleteColumn, $this->forcedKeys, true)
167        ) {
168            $this->forcedKeys[] = $metadata->softDeleteColumn;
169        }
170
171        $this->modelColumns = array_map(
172            fn($mapping) => $mapping->columnName,
173            array_values($metadata->properties),
174        );
175
176        parent::__construct($connection, $modelClass::table());
177
178        // The model's OWN columns ARE the default select â€” resolved to the
179        // explicit list (PK first) at construction, so no bare `*` ever
180        // rides a model query: a join meeting the default qualifies real
181        // columns (`table.*` only for joins whose second table shares no
182        // dedup hazard... it stays one qualified star), and a grammar sees
183        // a definite list. select('*') keeps its expansion contract (same
184        // list); the base's `['*']` sentinel never materializes here.
185        $this->columns = array_values(array_unique(array_merge(
186            $this->forcedKeys,
187            array_diff($this->modelColumns, $this->forcedKeys),
188        )));
189
190        // MTI: partition the merged columns per table and join the ancestor
191        // chain so the read path sees ONE virtual row spanning all levels.
192        if ($metadata->isMtiChild()) {
193            $this->partitions = $this->buildPartitions($metadata);
194            $this->applyMtiJoins($metadata);
195        }
196
197        // Tell the connection which column to return on insert (RETURNING /
198        // lastInsertId). A single auto-increment PK is server-generated; a
199        // single caller-assigned PK declares itself too â€” with the
200        // auto-increment flag false, so insertGetId()'s lastInsertId()
201        // fallback fails fast instead of returning a stale id (a composite
202        // PK declares nothing: no single column identifies the row).
203        $primaryKeys = $metadata->primaryKeys;
204        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
205            // Constructor context: assign directly (the immutable clone API
206            // is for post-construction callers).
207            $this->insertIdColumn = $primaryKeys[0]->name;
208            $this->insertIdAutoIncrement = $primaryKeys[0]->autoIncrement;
209        }
210        // Auto-apply every trait-declared scope. A trait's conditions group
211        // in ONE nested where group marked with the trait (`traitScope`
212        // marker) so the opt-outs (withTrashed/withoutScope/withoutScopes)
213        // strip the trait's whole scope atomically by MARKER, not positional
214        // index. Between traits the groups always AND â€” each trait's scope
215        // is a hard filter. MTI: the scope qualifies to the OWNING table
216        // (the column lives where the trait declared it).
217        // Nested builders skip this entirely â€” the scopes ride the OUTER
218        // builder; applying them here would recurse (whereNested â†’
219        // newNestedBuilder â†’ ctor â†’ whereNested).
220        $conditionsByTrait = [];
221
222        if (!$this->nested) {
223            foreach ($metadata->traitScopes as ['trait' => $scopeTrait, 'condition' => $condition]) {
224                $conditionsByTrait[$scopeTrait][] = $condition;
225            }
226        }
227
228        foreach ($conditionsByTrait as $scopeTrait => $conditions) {
229            // The constructor is the ONE place a builder finalizes its own
230            // state: the scope rides the instance being built, then is
231            // marked for marker-based removal.
232            $scoped = $this->whereNested(
233                function (WhereBuilder $nested) use ($conditions): WhereBuilder {
234                    // WhereBuilder is immutable â€” each where() returns a
235                    // NEW facade; thread it through the loop.
236                    $builder = $nested;
237
238                    foreach ($conditions as $index => $condition) {
239                        $column = $condition->column;
240
241                        if (isset($this->partitions[$column])) {
242                            $column = $this->partitions[$column] . '.' . $column;
243                        }
244
245                        // The first condition in the group carries no
246                        // boolean â€” the group's internal join starts with
247                        // the SECOND condition's declared boolean.
248                        $builder = $index === 0
249                            ? $builder->where($column, $condition->operator, $condition->value)
250                            : $builder->where($column, $condition->operator, $condition->value, $condition->boolean);
251                    }
252
253                    return $builder;
254                },
255            );
256            $scoped->markLastWhereTraitScope($scopeTrait);
257            $this->wheres = $scoped->getWheres();
258            // The group's VALUE bindings ride the clone whereNested()
259            // returned â€” copy them back alongside the wheres, or a scope
260            // condition with a value (e.g. `status = 'published'`) compiles
261            // to an unbound `= ?` and matches nothing.
262            $this->bindings = $scoped->bindings;
263        }
264    }
265
266    // ---- Soft-delete scope ----
267
268    /**
269     * Register relations to eager-load after hydration.
270     *
271     * Validation happens here â€” an unknown relation is a typo and fails
272     * fast at the with() call, not at hydration. Dot-notation nests:
273     * `'posts.comments'` loads posts, then each post's comments. Spread a
274     * computed list through the variadic: `->with(...$paths)`.
275     *
276     * @param  string  ...$relations
277     * @return static
278     * @throws \InvalidArgumentException
279     */
280    public function with(string ...$relations): static
281    {
282        foreach ($relations as $path) {
283            $this->assertRelationPath($path);
284            $this->validateRelationPath($path);
285        }
286
287        $clone = clone $this;
288
289        foreach ($relations as $path) {
290            $clone->eagerLoad[] = $path;
291        }
292
293        return $clone;
294    }
295
296    /**
297     * Assert one eager-load path is a non-empty string.
298     *
299     * The variadic types the elements at the language level â€” the empty
300     * path is the only violation a static analyzer cannot see, so it is
301     * the shape still checked at runtime.
302     *
303     * @param  string  $path  Must be a non-empty string naming a relation path.
304     * @return void
305     * @throws \InvalidArgumentException
306     */
307    private function assertRelationPath(string $path): void
308    {
309        if ($path === '') {
310            throw new \InvalidArgumentException(
311                'Relation paths must be non-empty strings; got an empty path.'
312            );
313        }
314    }
315
316    /**
317     * Assert a dotted relation path resolves to relation methods.
318     *
319     * @param  string  $path
320     * @return void
321     * @throws \InvalidArgumentException
322     */
323    protected function validateRelationPath(string $path): void
324    {
325        $class = $this->modelClass;
326
327        foreach (explode('.', $path) as $segment) {
328            $relation = static::resolveRelation($class, $segment, $path);
329            $classes = $relation->relatedClasses();
330
331            if ($classes === []) {
332                // A DYNAMIC related set (MorphTo) â€” the deeper segments
333                // cannot be validated statically; the runtime recursion
334                // resolves them off the actual loaded models.
335                return;
336            }
337
338            $class = $classes[0];
339        }
340    }
341
342    /**
343     * Resolve one relation-method name on a model class.
344     *
345     * @param  class-string<Model>  $class
346     * @param  string  $name
347     * @param  string  $path
348     * @return Relations\Relation<Model>
349     * @throws \InvalidArgumentException
350     */
351    protected static function resolveRelation(string $class, string $name, string $path): Relations\Relation
352    {
353        $cacheKey = $class . '::' . $name;
354
355        if (isset(static::$relationCache[$cacheKey])) {
356            return static::$relationCache[$cacheKey];
357        }
358
359        if (!method_exists($class, $name)) {
360            throw new \InvalidArgumentException(
361                "Unknown relation [{$path}] â€” model [{$class}] has no method [{$name}()]."
362            );
363        }
364
365        $reflection = new \ReflectionMethod($class, $name);
366
367        if (!$reflection->isPublic()) {
368            throw new \InvalidArgumentException(
369                "Relation [{$path}] â€” method [{$class}::{$name}()] is not public; relations must be callable."
370            );
371        }
372
373        // A relation method takes no arguments and returns a Relation.
374        // Invoke it on a detached instance (the newInstance() hook â€”
375        // hydration without constructor) so the parent's attribute reads
376        // see nulls rather than an uninitialized-property error.
377        $prototype = $class::newInstance();
378
379        /** @var mixed $result */
380        $result = $reflection->invoke($prototype);
381
382        if (!$result instanceof Relations\Relation) {
383            throw new \InvalidArgumentException(
384                "Unknown relation [{$path}] â€” method [{$class}::{$name}()] does not return a Relation."
385            );
386        }
387
388        return static::$relationCache[$cacheKey] = $result;
389    }
390
391    /**
392     * Eager-load the registered relations onto a hydrated collection.
393     *
394     * One extra query per relation path â€” an `IN` on the FK, no joins, no
395     * row multiplication.
396     *
397     * @template TLoaded of Model
398     *
399     * @param  Collection<int, TLoaded>  $models
400     * @return void
401     */
402    protected function eagerLoadRelations(Collection $models): void
403    {
404        foreach ($this->eagerLoad as $path) {
405            $this->loadRelationPath($models, $path);
406        }
407    }
408
409    /**
410     * Load one dotted relation path onto the models.
411     *
412     * @template TLoaded of Model
413     *
414     * @param  Collection<int, TLoaded>  $models
415     * @param  string  $path
416     * @return void
417     */
418    public function loadRelationPath(Collection $models, string $path): void
419    {
420        [$name, $nested] = $this->splitPath($path);
421
422        /** @var list<Model> $modelsArray */
423        $modelsArray = $models->all();
424
425        if ($modelsArray === []) {
426            return;
427        }
428
429        // The relation prototype comes off the FIRST model (all models in
430        // a collection are the same class â€” that is the hydration contract).
431        $first = $modelsArray[0];
432        $relation = static::resolveRelation($first::class, $name, $path);
433
434        $this->loadRelation($modelsArray, $relation, $name, $nested, $path);
435    }
436
437    /**
438     * Split a dotted path into its first segment and the nested remainder.
439     *
440     * @param  string  $path  The full path.
441     * @return array{string, string|null} The first segment plus the remainder, or null when the path has one segment.
442     */
443    protected function splitPath(string $path): array
444    {
445        $dot = strpos($path, '.');
446
447        if ($dot === false) {
448            return [$path, null];
449        }
450
451        return [substr($path, 0, $dot), substr($path, $dot + 1)];
452    }
453
454    /**
455     * Run the eager query for a relation and stitch the results.
456     *
457     * @param  list<Model>  $parents
458     * @param  Relations\Relation<Model>  $relation
459     * @param  string  $name
460     * @param  string|null  $nested
461     * @param  string  $path
462     * @return void
463     */
464    protected function loadRelation(array $parents, Relations\Relation $relation, string $name, ?string $nested, string $path): void
465    {
466        // Collect the parents' key values for the IN clause. The column the
467        // keys come from is relation-specific: HasOne/HasMany/through read
468        // the parent's LOCAL key (the related table's FK points at it), while
469        // BelongsTo reads the parent's FOREIGN key (it points at the related
470        // table's owner key). BelongsTo overrides the accessor so the loader
471        // stays key-agnostic. A composite key collects the full tuple â€”
472        // deduped by its serialized form, distinct values in first-seen order.
473        $keys = [];
474        $keyIndex = [];
475
476        foreach ($parents as $parent) {
477            $column = $relation->eagerKeyColumn();
478
479            if (is_string($column)) {
480                $value = $parent->attribute($column);
481
482                if ($value === null) {
483                    continue;
484                }
485
486                $serialized = (string) $value;
487                $keys[$serialized] = true;
488                $keyIndex[$serialized] = $value;
489
490                continue;
491            }
492
493            $tuple = [];
494
495            foreach ($column as $keyColumn) {
496                $tuple[$keyColumn] = $parent->attribute($keyColumn);
497            }
498
499            if (in_array(null, $tuple, true)) {
500                continue; // a null key component matches nothing â€” skip
501            }
502
503            $serialized = json_encode($tuple, JSON_THROW_ON_ERROR);
504            $keys[$serialized] = true;
505            $keyIndex[$serialized] = $tuple;
506        }
507
508        if ($keys === []) {
509            foreach ($parents as $parent) {
510                // A single-valued relation (HasOne/BelongsTo/MorphTo) loads
511                // as NULL when no parent carries a key; a to-many relation
512                // loads as an EMPTY collection. The relation's cardinality
513                // decides â€” a probe match against an empty result set
514                // keeps the shapes honest without special-casing names.
515                $empty = Collection::make([]);
516
517                $relation->match([$parent], $empty, $name, []);
518            }
519
520            return;
521        }
522
523        $keyList = array_values($keyIndex);
524
525        $result = $relation->eagerLoad($keyList);
526
527        $relation->match($parents, $result->models, $name, $result->parentKeys);
528
529        if ($nested !== null) {
530            // Recurse onto the freshly-loaded related models.
531            /** @var list<Model> $children */
532            $children = [];
533
534            foreach ($parents as $parent) {
535                $value = $parent->cachedRelation($name);
536
537                if ($value instanceof Collection) {
538                    foreach ($value as $child) {
539                        $children[] = $child;
540                    }
541                } elseif ($value instanceof Model) {
542                    $children[] = $value;
543                }
544            }
545
546            if ($children !== []) {
547                // Recurse ONE segment at a time: split the remaining dotted
548                // path, resolve only its first segment as a method name, and
549                // pass the rest down as the next level's nested path. Passing
550                // the whole remainder (`b.c`) as a method name would fail for
551                // any path three or more levels deep.
552                [$childName, $childNested] = $this->splitPath($nested);
553                $childRelation = static::resolveRelation($children[0]::class, $childName, $path);
554                $this->loadRelation($children, $childRelation, $childName, $childNested, $path);
555            }
556        }
557    }
558
559    // ---- MTI (multi-table inheritance) reads ----
560
561    /**
562     * Build the column â†’ owning-table partition map from the metadata.
563     *
564     * @param  \BlueprintAU\Radiant\Metadata\ClassMetadata  $metadata
565     * @return array<string, string>
566     */
567    protected function buildPartitions(\BlueprintAU\Radiant\Metadata\ClassMetadata $metadata): array
568    {
569        $partitions = [];
570
571        foreach ($metadata->properties as $mapping) {
572            $partitions[$mapping->columnName] = $metadata->tableFor($mapping->columnName);
573        }
574
575        return $partitions;
576    }
577
578    /**
579     * INNER JOIN every ancestor table on the shared PK and select each
580     * level's columns qualified + aliased back to the plain columnName.
581     *
582     * @param  \BlueprintAU\Radiant\Metadata\ClassMetadata  $metadata
583     * @return void
584     */
585    protected function applyMtiJoins(\BlueprintAU\Radiant\Metadata\ClassMetadata $metadata): void
586    {
587        $table = $metadata->tableName;
588        $this->mtiChain = [];
589
590        // Walk the parent chain; each table-owning level joins on the
591        // shared PK (same-named key columns ARE the link).
592        $parent = $metadata->parentModel;
593
594        while ($parent !== null) {
595            $parentMetadata = MetadataFactory::for($parent);
596            $parentTable = $parentMetadata->tableName;
597
598            if ($parentTable !== null) {
599                $pk = $parentMetadata->primaryKeys[0]->name ?? 'id';
600
601                $this->joins[] = [
602                    'type' => JoinType::Inner,
603                    'table' => $parentTable,
604                    'wheres' => [[
605                        'type' => WhereType::Column,
606                        'first' => "{$table}.{$pk}",
607                        'operator' => ColumnOperator::Eq,
608                        'second' => "{$parentTable}.{$pk}",
609                        'boolean' => WhereBoolean::And,
610                    ]],
611                ];
612
613                $this->mtiChain[] = [$parent, $parentTable];
614            }
615
616            $parent = $parentMetadata->parentModel;
617        }
618
619        // Re-select every column qualified + aliased back to its plain
620        // columnName â€” raw keys stay unambiguous across all levels. Plain
621        // `table.column as column` specs: the Grammar's wrapColumn() owns
622        // the quoting AND the AS rendering. Constructor-time: the builder
623        // finalizes its own state here (the one self-mutation point).
624        $selects = [];
625
626        foreach ($this->modelColumns as $column) {
627            $ownerTable = $this->partitions[$column] ?? $table;
628            $selects[] = "{$ownerTable}.{$column} as {$column}";
629        }
630
631        $this->columns = $selects;
632    }
633
634    /**
635     * Add a column-to-column comparison with model-aware column validation.
636     *
637     * @param  string  $first
638     * @param  ColumnOperator|string  $operator
639     * @param  string  $second
640     * @param  WhereBoolean  $boolean
641     * @return static
642     * @throws \InvalidArgumentException
643     */
644    public function whereColumn(string $first, ColumnOperator|string $operator = '=', string $second = '', WhereBoolean $boolean = WhereBoolean::And): static
645    {
646        $this->validateColumn($first);
647        $this->validateColumn($second);
648
649        return parent::whereColumn($first, $operator, $second, $boolean);
650    }
651    /**
652     * Add a raw SQL where clause.
653     *
654     * @param  string  $sql
655     * @param  array<int, mixed>  $bindings
656     * @param  WhereBoolean  $boolean
657     * @return static
658     */
659    public function whereRaw(string $sql, array $bindings = [], WhereBoolean $boolean = WhereBoolean::And): static
660    {
661        return parent::whereRaw($sql, $bindings, $boolean);
662    }
663
664    /**
665     * Add an `EXISTS (subquery)` clause to the query.
666     *
667     * There is no outer column to validate â€” the subquery's columns were
668     * validated against its own model at declaration. Declare the
669     * correlation with {@see correlateWith()} before referencing outer
670     * tables.
671     *
672     * @param  QueryBuilder  $query  The existential subquery.
673     * @param  WhereBoolean  $boolean
674     * @param  bool  $negated  True renders `NOT EXISTS`.
675     * @return static
676     */
677    #[\Override]
678    public function whereExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static
679    {
680        return parent::whereExists($query, $boolean, $negated);
681    }
682
683    /**
684     * Add a `column IN (subquery)` clause to the query.
685     *
686     * The outer column validates against this model's allowlist; the
687     * subquery's columns validate against its own model.
688     *
689     * @param  string  $column  The outer column the IN constrains.
690     * @param  QueryBuilder  $query  The single-column value subquery.
691     * @param  WhereBoolean  $boolean
692     * @param  bool  $negated  True renders `NOT IN`.
693     * @return static
694     * @throws \InvalidArgumentException
695     */
696    #[\Override]
697    public function whereInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static
698    {
699        $this->validateColumn($column);
700
701        return parent::whereInQuery($column, $query, $boolean, $negated);
702    }
703
704    /**
705     * Declare this builder a correlated subquery of the given outer
706     * builder.
707     *
708     * Column validation is eager, so this must be called before the
709     * correlated clauses: afterward, qualified specs may reference the
710     * outer's tables (plus its join tables and aliases), while a
711     * foreign-table reference without it still throws at the clause call.
712     *
713     * @param  QueryBuilder  $outer  The builder this subquery correlates against.
714     * @return static
715     */
716    public function correlateWith(QueryBuilder $outer): static
717    {
718        $tables = [$outer->table];
719
720        foreach ($outer->getJoins() as $join) {
721            if (preg_match('/^(.*?)\s+as\s+(\S+)$/i', $join['table'], $m) === 1) {
722                $tables[] = trim($m[1]);
723                $tables[] = $m[2];
724            } else {
725                $tables[] = $join['table'];
726            }
727        }
728
729        $clone = clone $this;
730        $clone->correlationTables = $tables;
731        return $clone;
732    }
733
734    /**
735     * Create a new builder for a nested where group.
736     *
737     * @return QueryBuilder
738     * @throws \InvalidArgumentException
739     */
740    protected function newNestedBuilder(): QueryBuilder
741    {
742        $nested = new self($this->modelClass, $this->connection, nested: true);
743
744        // A nested where group may reference columns on tables the OUTER
745        // query joins (e.g. a through-relation constraint qualifying the
746        // intermediate table). validateColumn() admits qualified names by
747        // checking the joins list, so the nested builder must see the outer
748        // joins. The copy is validation-only: whereNested() compiles the
749        // group from getWheres() alone â€” the nested builder's joins are
750        // never compiled, so no double-join is possible.
751        $nested->joins = $this->joins;
752
753        return $nested;
754    }
755
756    /**
757     * Add a join, shielding and qualifying the select for the second
758     * table the join introduces.
759     *
760     * A join turns previously-harmless select shapes ambiguous: under a
761     * raw `*`, duplicate names (every table has an `id`) collide last-wins
762     * in the fetched row â€” hydration would decode the joined table's
763     * values through THIS model's casts; under bare column names, the
764     * shared names fail at the driver as ambiguous columns. The clone's
765     * select is therefore QUALIFIED as join-aware: a raw star (the
766     * untouched default, or a hand-built bare star among specs) becomes
767     * `table.*`, bare column specs become `table.column` â€” producing the
768     * SAME compiled list whichever way the caller ordered `select()` and
769     * `join()` (select() applies the identical qualification once the
770     * joins exist). Qualified specs, Aliases, Aggregates and Expressions
771     * pass through untouched.
772     *
773     * @param  JoinType  $type
774     * @param  string  $table
775     * @param  string  $first
776     * @param  ColumnOperator|string  $operator
777     * @param  string  $second
778     * @return static
779     * @throws \InvalidArgumentException
780     */    #[\Override]
781    protected function addJoin(JoinType $type, string $table, string $first, ColumnOperator|string $operator, string $second): static
782    {
783        $clone = parent::addJoin($type, $table, $first, $operator, $second);
784
785        // Per-spec qualify: every bare name prefixes with this model's
786        // table; every raw star in place becomes `table.*`. The untouched
787        // default (`['*']`) therefore collapses to exactly one qualified
788        // star, while a hand-built mixed shape (`count(*) AS t`, `*`,
789        // `name`) keeps its aggregate AND its other columns â€” each star
790        // shields in place, none of the caller's specs are dropped.
791        // Bare names selected BEFORE the join are already in `$columns` â€”
792        // qualifying them closes the join-first half of the ordering
793        // (select() handles its OWN bare specs post-join).
794        $clone->columns = $this->qualifyColumns($clone->columns);
795
796        return $clone;
797    }
798
799    /**
800     * Qualify bare specs in an arbitrary column list â€” the shared body
801     * of {@see qualifyForJoin()} and {@see addJoin()}'s ordering shield.
802     *
803     * Every plain-string spec WITHOUT a `.` gets this model's table
804     * prefix â€” including a raw `*`, which becomes `table.*` IN PLACE (a
805     * mixed shape keeps its sibling specs); Aggregate, Expression,
806     * SubquerySelect and already-qualified entries pass through.
807     *
808     * @param  list<string|Expression|Aggregate|SubquerySelect>  $columns
809     * @return list<string|Expression|Aggregate|SubquerySelect>
810     */
811    private function qualifyColumns(array $columns): array
812    {
813        $qualified = [];
814
815        foreach ($columns as $column) {
816            if ($column instanceof Expression || $column instanceof Aggregate || $column instanceof SubquerySelect) {
817                $qualified[] = $column;
818                continue;
819            }
820
821            $qualified[] = str_contains($column, '.')
822                ? $column
823                : $this->table . '.' . $column;
824        }
825
826        return $qualified;
827    }
828
829    /**
830     * Append an ON condition to the last added join, with validation.
831     *
832     * @param  string  $first
833     * @param  ColumnOperator|string  $operator
834     * @param  string  $second
835     * @return static
836     * @throws \LogicException
837     * @throws \InvalidArgumentException
838     */
839    public function on(string $first, ColumnOperator|string $operator = '=', string $second = ''): static
840    {
841        $this->validateColumn($first);
842        $this->validateColumn($second);
843
844        return parent::on($first, $operator, $second);
845    }
846
847    /**
848     * Append an OR-connected ON condition to the last added join, with
849     * validation.
850     *
851     * @param  string  $first
852     * @param  ColumnOperator|string  $operator
853     * @param  string  $second
854     * @return static
855     * @throws \LogicException
856     * @throws \InvalidArgumentException
857     */
858    public function orOn(string $first, ColumnOperator|string $operator = '=', string $second = ''): static
859    {
860        $this->validateColumn($first);
861        $this->validateColumn($second);
862
863        return parent::orOn($first, $operator, $second);
864    }
865
866    // ---- Trait scopes ----
867
868    // The soft-delete query vocabulary lives HERE, not on the SoftDeletes
869    // trait: these methods return a builder (fluent chains) and strip/re-mark
870    // the traitScope where-markers below â€” query state a model-side trait
871    // method cannot own. The trait keeps the row behavior (scope, delete
872    // hook, restore/trashed).
873
874    /**
875     * Include soft-deleted rows â€” strips only the SoftDeletes scope.
876     *
877     * @return static
878     */
879    public function withTrashed(): static
880    {
881        return $this->withoutScope(SoftDeletes::class);
882    }
883
884    /**
885     * Only soft-deleted rows â€” strips the scope and adds a marked
886     * `whereNotNull` so the toggle round-trips.
887     *
888     * @return static
889     */
890    public function onlyTrashed(): static
891    {
892        $metadata = MetadataFactory::for($this->modelClass);
893        $column = $metadata->softDeleteColumn;
894
895        if ($column === null) {
896            throw new \LogicException(
897                "Model [{$this->modelClass}] does not use SoftDeletes; onlyTrashed() is unavailable."
898            );
899        }
900
901        // First clear any existing soft-delete state (scope and/or a
902        // previous onlyTrashed clause) so the toggle is idempotent.
903        $cleared = $this->withTrashed();
904
905        if (isset($cleared->partitions[$column])) {
906            $column = $cleared->partitions[$column] . '.' . $column;
907        }
908
909        $scoped = $cleared->whereNotNull($column);
910        $scoped->markLastWhereTraitScope(SoftDeletes::class);
911
912        $clone = clone $scoped;
913        return $clone;
914    }
915
916    /**
917     * Strip every where clause declared by one trait's scope.
918     *
919     * @param  class-string  $trait
920     * @return static
921     */
922    public function withoutScope(string $trait): static
923    {
924        $wheres = $this->getWheres();
925
926        $filtered = array_values(array_filter(
927            $wheres,
928            fn(array $where): bool => ($where['traitScope'] ?? null) !== $trait,
929        ));
930
931        if ($filtered === $wheres) {
932            return $this; // nothing to remove â€” reuse the instance.
933        }
934
935        $clone = clone $this;
936        $clone->wheres = $filtered;
937        return $clone;
938    }
939
940    /**
941     * Strip every trait-declared scope.
942     *
943     * @return static
944     */
945    public function withoutScopes(): static
946    {
947        $wheres = $this->getWheres();
948
949        $filtered = array_values(array_filter(
950            $wheres,
951            fn(array $where): bool => !array_key_exists('traitScope', $where),
952        ));
953
954        if ($filtered === $wheres) {
955            return $this; // nothing to remove â€” reuse the instance.
956        }
957
958        $clone = clone $this;
959        $clone->wheres = $filtered;
960        return $clone;
961    }
962
963    // ---- Execution (hydration) ----
964
965    /**
966     * Run the query and hydrate every row into a model.
967     *
968     * @return Collection<int, TModel>
969     *
970     * @phpstan-ignore method.childReturnType, generics.variance
971     */
972    public function get(): Collection
973    {
974        $rows = parent::get();
975
976        $models = array_map(
977            fn(\stdClass $row) => $this->modelClass::fromRow($row),
978            $rows->all(),
979        );
980
981        $collection = Collection::make($models);
982
983        $this->eagerLoadRelations($collection);
984
985        return $collection;
986    }
987
988    /**
989     * Get the raw rows (stdClass), bypassing hydration.
990     *
991     * @return BaseCollection<int, \stdClass>
992     */
993    public function getRaw(): BaseCollection
994    {
995        return parent::get();
996    }
997
998    /**
999     * Stream the query, hydrating each row into a model as it arrives.
1000     *
1001     * Eager loads cannot ride a stream â€” use `get()` when relations are
1002     * required.
1003     *
1004     * @return \Generator<int, TModel>
1005     *
1006     * @phpstan-ignore method.childReturnType
1007     */
1008    public function cursor(): \Generator
1009    {
1010        foreach ($this->connection->cursor($this) as $row) {
1011            yield $this->modelClass::fromRow($row);
1012        }
1013    }
1014
1015    /**
1016     * Run the query and hydrate the first row.
1017     *
1018     * @return TModel|null
1019     */
1020    public function first(): ?Model
1021    {
1022        // limit() is immutable â€” it returns a scoped clone, leaving this
1023        // builder's own limit untouched.
1024        $rows = $this->connection->select($this->limit(1));
1025        $row = $rows[0] ?? null;
1026
1027        if ($row === null) {
1028            return null;
1029        }
1030
1031        $model = $this->modelClass::fromRow($row);
1032
1033        // Eager loads apply to single-model reads too.
1034        if ($this->eagerLoad !== []) {
1035            $single = Collection::make([$model]);
1036
1037            $this->eagerLoadRelations($single);
1038        }
1039
1040        return $model;
1041    }
1042
1043    /**
1044     * Find a model by primary key.
1045     *
1046     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
1047     * @return TModel|null
1048     */
1049    public function find(int|string|null|array $id): ?Model
1050    {
1051        return $this->whereKey($id)->first();
1052    }
1053
1054    /**
1055     * Get the first hydrated row or throw if no rows match.
1056     *
1057     * @return TModel
1058     *
1059     * @throws ModelNotFoundException
1060     */
1061    public function firstOrFail(): Model
1062    {
1063        return (clone $this)->firstOrFailWithKey(null);
1064    }
1065
1066    /**
1067     * Find a model by primary key or throw if it does not exist.
1068     *
1069     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
1070     * @return TModel
1071     *
1072     * @throws ModelNotFoundException
1073     */
1074    public function findOrFail(int|string|null|array $id): Model
1075    {
1076        // whereKey() also runs on the clone â€” the added wheres never land
1077        // on the shared builder.
1078        return (clone $this)->whereKey($id)->firstOrFailWithKey($id);
1079    }
1080
1081    /**
1082     * Get the single matching row or throw if the count differs.
1083     *
1084     * @return TModel
1085     *
1086     * @throws ModelNotFoundException
1087     * @throws MultipleRecordsFoundException
1088     */
1089    public function sole(): Model
1090    {
1091        // select() returns a Collection (not a bare array) â€” count it,
1092        // never `=== []`.
1093        $rows = $this->connection->select((clone $this)->limit(2));
1094        $rowCount = \count($rows);
1095
1096        if ($rowCount === 0) {
1097            throw new ModelNotFoundException($this->modelClass);
1098        }
1099
1100        if ($rowCount > 1) {
1101            throw new MultipleRecordsFoundException($rowCount, $this->modelClass);
1102        }
1103
1104        $row = $rows[0] ?? null;
1105
1106        if (!$row instanceof \stdClass) {
1107            throw new ModelNotFoundException($this->modelClass);
1108        }
1109
1110        $model = $this->modelClass::fromRow($row);
1111
1112        // Eager loads apply to single-model reads too â€” same tail as first().
1113        if ($this->eagerLoad !== []) {
1114            $single = Collection::make([$model]);
1115
1116            $this->eagerLoadRelations($single);
1117        }
1118
1119        return $model;
1120    }
1121
1122    /**
1123     * The shared fail-fast fetch behind {@see firstOrFail()} and
1124     * {@see findOrFail()}.
1125     *
1126     * @param  KeyValue|null  $keyForMessage  The lookup key for the exception, or null.
1127     * @return TModel
1128     *
1129     * @throws ModelNotFoundException
1130     */
1131    private function firstOrFailWithKey(int|string|null|array $keyForMessage): Model
1132    {
1133        $model = $this->first();
1134
1135        if ($model === null) {
1136            throw new ModelNotFoundException($this->modelClass, $keyForMessage);
1137        }
1138
1139        return $model;
1140    }
1141
1142    // ---- Find-or-create ----
1143
1144    /**
1145     * Return the first matching model, or create one carrying the wheres.
1146     *
1147     * The builder's wheres are the match. Every simple `column = value`
1148     * clause over a declared column becomes both a match clause and a
1149     * fill, so the created model satisfies the match that failed to find
1150     * it; non-invertible wheres fail fast unless their column is carried
1151     * in `$values` (trait scopes never seed a fill). The lookup-then-insert
1152     * is not atomic: against a `#[Unique]`-backed column a lost race
1153     * surfaces as a QueryException.
1154     *
1155     * @param  array<string, mixed>  $values  Extra column values for the created model.
1156     * @return TModel
1157     * @throws \InvalidArgumentException
1158     * @throws WriteVetoException
1159     */
1160    public function firstOrCreate(array $values = []): Model
1161    {
1162        $model = $this->first();
1163
1164        if ($model !== null) {
1165            return $model;
1166        }
1167
1168        return $this->createModel($this->invertibleWhereFills($values), $values);
1169    }
1170
1171    /**
1172     * Find a model by primary key, or create one carrying that key.
1173     *
1174     * The key map is both the match and the fill, so a miss always
1175     * creates an addressable row; a scalar expands to the single PK, a
1176     * composite key passes the full column => value map. A
1177     * non-auto-generated key left without a value fails fast. The
1178     * lookup-then-insert is not atomic: against a `#[Unique]`-backed key
1179     * a lost race surfaces as a QueryException.
1180     *
1181     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
1182     * @param  array<string, mixed>  $values  Extra column values for the created model.
1183     * @return TModel
1184     * @throws \InvalidArgumentException
1185     * @throws WriteVetoException
1186     */
1187    public function findOrCreate(int|string|null|array $id, array $values = []): Model
1188    {
1189        $model = $this->find($id);
1190
1191        if ($model !== null) {
1192            return $model;
1193        }
1194
1195        // The key is authoritative; the builder's invertible wheres fill
1196        // beneath it. PHP's + keeps the left operand's entries on collision.
1197        return $this->createModel($this->keyFills($id) + $this->invertibleWhereFills($values), $values);
1198    }
1199
1200    /**
1201     * Build, fill, and save a new model.
1202     *
1203     * The shared miss path. A vetoed save throws â€” the
1204     * {@see WriteVetoException} propagates to the caller and no row
1205     * lands. Synthetic columns route through {@see Model::setAttribute()};
1206     * typed-property columns through {@see Model::setColumn()}.
1207     *
1208     * @param  array<string, mixed>  $match  The match-derived fills.
1209     * @param  array<string, mixed>  $values  The caller's create-only extras.
1210     * @return TModel
1211     * @throws \InvalidArgumentException
1212     * @throws WriteVetoException
1213     */
1214    private function createModel(array $match, array $values): Model
1215    {
1216        $metadata = MetadataFactory::for($this->modelClass);
1217
1218        // Auto-increment single PKs skip the guard â€” the INSERT generates
1219        // the key. A caller-assigned or composite key must be fully
1220        // covered by the fills, or the row is born unaddressable.
1221        $pks = $metadata->primaryKeys;
1222        $generated = count($pks) === 1 && $pks[0]->autoIncrement;
1223
1224        if (!$generated) {
1225            $missing = [];
1226
1227            foreach ($pks as $pk) {
1228                $name = $pk->name;
1229
1230                if ($name !== null && !array_key_exists($name, $match) && !array_key_exists($name, $values)) {
1231                    $missing[] = $name;
1232                }
1233            }
1234
1235            if ($missing !== []) {
1236                throw new \InvalidArgumentException(
1237                    'Creating model [' . $this->modelClass . '] needs its primary-key column(s) '
1238                        . '[' . implode(', ', $missing) . '] in the match or the values â€” the key is '
1239                        . 'not auto-generated, so the INSERT would produce a row no later lookup '
1240                        . 'can address.'
1241                );
1242            }
1243        }
1244
1245        /** @var TModel $model */
1246        $model = $this->modelClass::newInstance();
1247
1248        foreach ($match + $values as $column => $value) {
1249            if ($metadata->mappingFor($column)->property === null) {
1250                $model->setAttribute($column, $value);
1251
1252                continue;
1253            }
1254
1255            $model->setColumn($column, $value);
1256        }
1257
1258        $model->save();
1259
1260        return $model;
1261    }
1262
1263    /**
1264     * Extract the invertible where fills from the builder's where state.
1265     *
1266     * A simple `column = value` over a declared column is invertible â€”
1267     * the clause's value seeds the column. Trait-scope-marked groups
1268     * (the soft-delete filter) are framework-owned and skipped, as is a
1269     * non-invertible where whose column `$values` covers. Every other
1270     * shape fails fast.
1271     *
1272     * @param  array<string, mixed>  $values  The caller's create-only extras â€” the seeded constraint check.
1273     * @return array<string, mixed>
1274     * @throws \InvalidArgumentException
1275     */
1276    private function invertibleWhereFills(array $values): array
1277    {
1278        $fills = [];
1279
1280        foreach ($this->getWheres() as $where) {
1281            // Trait scopes are the framework's own filter (the soft-delete
1282            // `deleted_at IS NULL` group) â€” they must filter the lookup
1283            // but never seed a fill.
1284            if (array_key_exists('traitScope', $where)) {
1285                continue;
1286            }
1287
1288            if ($where['type'] === WhereType::Basic
1289                && $where['operator'] === WhereOperator::Eq
1290                && is_string($where['column'])
1291            ) {
1292                $column = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $where['column']));
1293
1294                if (MetadataFactory::for($this->modelClass)->hasColumn($column)) {
1295                    $fills[$column] = $where['value'];
1296
1297                    continue;
1298                }
1299            }
1300
1301            // A non-invertible shape whose column the caller seeds in
1302            // `$values` is satisfied by that value.
1303            if ($where['type'] === WhereType::Basic && is_string($where['column'])) {
1304                $column = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $where['column']));
1305
1306                if (array_key_exists($column, $values)
1307                    && MetadataFactory::for($this->modelClass)->hasColumn($column)
1308                ) {
1309                    continue;
1310                }
1311            }
1312
1313            throw new \InvalidArgumentException(
1314                'firstOrCreate() cannot invert the where clause into a fill â€” only simple '
1315                    . '`column = value` clauses over declared columns can seed the created model, '
1316                    . 'or the constraint\'s column can be carried in the values explicitly. '
1317                    . 'Compose the constraint so every filter is an equality on the builder.'
1318            );
1319        }
1320
1321        return $fills;
1322    }
1323
1324    /**
1325     * The PK fills for a findOrCreate miss â€” the key map itself.
1326     *
1327     * The lookup already validated the shapes (whereKey's guards run on
1328     * the read); this only asserts the value side is present so the map
1329     * is directly writable. The mixed parameter is the runtime boundary â€”
1330     * the KeyValue contract on the public method is PHPDoc-only, so the
1331     * list/null shapes are still checked here.
1332     *
1333     * @param  mixed  $id  The validated KeyValue from the public method.
1334     * @return array<string, mixed>
1335     *
1336     * @throws \InvalidArgumentException
1337     */
1338    private function keyFills(mixed $id): array
1339    {
1340        if ($id === null) {
1341            throw new \InvalidArgumentException(
1342                'findOrCreate() cannot create from a null key â€” the created model would have '
1343                    . 'no primary key.'
1344            );
1345        }
1346
1347        if (is_array($id) && array_is_list($id)) {
1348            throw new \InvalidArgumentException(
1349                'findOrCreate() takes a single key (scalar or column => value map), not a key '
1350                    . 'list â€” a list matches several rows, and a create has exactly one target.'
1351            );
1352        }
1353
1354        if (is_array($id)) {
1355            return $id;
1356        }
1357
1358        $pkName = MetadataFactory::for($this->modelClass)->primaryKeys[0]->name;
1359
1360        if ($pkName === null) {
1361            throw new \InvalidArgumentException(
1362                "Model [{$this->modelClass}] has an unnamed primary key; pass a column => value map."
1363            );
1364        }
1365
1366        return [$pkName => $id];
1367    }
1368
1369    // ---- Scalar reads (decoded through the column casts) ----
1370
1371    /**
1372     * The value of a single column from the first row, decoded through
1373     * the column's cast.
1374     *
1375     * Raw SQL and user-aliased columns pass through raw â€” the model layer
1376     * has no cast for a computed value.
1377     *
1378     * @param  string|Aggregate  $column
1379     * @return mixed
1380     */
1381    public function value(string|Aggregate $column): mixed
1382    {
1383        if ($column instanceof Aggregate) {
1384            $rows = $this->connection->select(
1385                $this->scopedFor(new Aggregate($column->function, $column->column, 'radiant_scalar'))->limit(1),
1386            );
1387            $raw = $rows[0] ?? null;
1388
1389            // An Expression argument is a computed value â€” no cast applies.
1390            return $column->column instanceof Expression
1391                ? ($raw === null ? null : $raw->radiant_scalar)
1392                : $this->decodeScalar($column->column, $raw === null ? null : $raw->radiant_scalar);
1393        }
1394
1395        $sql = $this->scalarColumn($column);
1396
1397        // Fetch the RAW column directly (mirroring first()'s raw-row
1398        // fetch): the hydrating first() would return a Model, which has no
1399        // scalar property to read the value back from. The columnar fetch
1400        // reads the single column positionally â€” no per-row object
1401        // materialized. The scoped builder keeps THIS builder's select
1402        // untouched.
1403        $raw = $this->connection->selectColumn($this->scopedFor($sql)->limit(1))->first();
1404
1405        return $this->decodeScalar($column, $raw);
1406    }
1407
1408    /**
1409     * A collection of a single column's values, decoded through the casts.
1410     *
1411     * @param  string  $column
1412     * @return BaseCollection<int, mixed>
1413     */
1414    public function pluck(string $column): BaseCollection
1415    {
1416        $sql = $this->scalarColumn($column);
1417
1418        // The columnar fetch: values come back positionally, one per row â€”
1419        // no per-row object materialized, no alias read per row. map()
1420        // preserves the 0-based list keys â€” the result is already a list,
1421        // so no trailing values() re-index (it would be a no-op copy).
1422        return $this->connection->selectColumn($this->scopedFor($sql))->map(
1423            fn(mixed $raw) => $this->decodeScalar($column, $raw),
1424        );
1425    }
1426
1427    /**
1428     * A clone of this builder scoped to a scalar or aggregate select.
1429     *
1430     * The clone carries the constraints (wheres, joins, soft-delete state)
1431     * but owns its own column list â€” the bypass primitive for internal
1432     * scalar reads, where `select()` cannot be used (it re-merges the
1433     * forced PK and validates against the declared columns).
1434     *
1435     * @param  string|Aggregate  ...$sql
1436     * @return static
1437     */
1438    protected function scopedFor(string|Aggregate ...$sql): static
1439    {
1440        $clone = clone $this;
1441        $clone->columns = $sql === [] ? ['*'] : array_values($sql);
1442
1443        return $clone;
1444    }
1445
1446    /**
1447     * Decode one scalar read when the column is a declared model column.
1448     *
1449     * @param  string  $column
1450     * @param  mixed  $raw
1451     * @return mixed
1452     */
1453    private function decodeScalar(string $column, mixed $raw): mixed
1454    {
1455        $bare = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column));
1456        $metadata = MetadataFactory::for($this->modelClass);
1457
1458        if (!$metadata->hasColumn($bare)) {
1459            return $raw;
1460        }
1461
1462        $mapping = $metadata->mappingFor($bare);
1463
1464        return $mapping->column->decode($raw, $mapping->propertyType);
1465    }
1466
1467    /**
1468     * Encode one where value when the column is a declared model column
1469     * â€” the where-path twin of {@see decodeScalar()}.
1470     *
1471     * The value routes through the column's cast â€” the same
1472     * {@see Column::encode()} the write path uses â€” so every shape the
1473     * write path accepts filters identically: enum cases, datetimes,
1474     * Json arrays, int timestamps. Raw scalars pass through when valid
1475     * (the cast's idempotence trade) and fail fast naming the column
1476     * when not. Raw SQL expressions ({@see Expression}/{@see ToSqlValue})
1477     * and LIKE patterns skip the cast: the former splice verbatim and
1478     * must never be encoded; the latter is a match template, not a cell
1479     * value. A value on an unknown (or joined, or synthetic) column is
1480     * returned untouched too â€” the binding layer rejects non-bindables
1481     * as before, and a raw scalar on a joined column has no model cast
1482     * to consult.
1483     *
1484     * @param  string  $column
1485     * @param  mixed  $value
1486     * @param  WhereOperator|string|null  $operator  The clause operator, when known â€” the LIKE shapes bypass encoding.
1487     * @return mixed
1488     * @throws \InvalidArgumentException
1489     */
1490    private function encodeWhereValue(string $column, mixed $value, WhereOperator|string|null $operator = null): mixed
1491    {
1492        if ($value === null || $value instanceof Expression || $value instanceof ToSqlValue) {
1493            return $value;
1494        }
1495
1496        if ($operator instanceof WhereOperator
1497            ? ($operator === WhereOperator::Like || $operator === WhereOperator::NotLike)
1498            : ($operator === 'LIKE' || $operator === 'NOT LIKE')
1499        ) {
1500            return $value;
1501        }
1502
1503        $bare = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column));
1504        $metadata = MetadataFactory::for($this->modelClass);
1505
1506        if (!$metadata->hasColumn($bare)) {
1507            return $value;
1508        }
1509
1510        $mapping = $metadata->mappingFor($bare);
1511        $propertyType = $mapping->propertyType;
1512
1513        // The enum identity guard: the cast's enum arm encodes ANY enum
1514        // case to its backing value â€” it never needed a class check
1515        // because the write path enforces the column's enum through the
1516        // typed property. A where/having value has no property to enforce
1517        // it, so a case of a DIFFERENT enum fails fast here instead of
1518        // silently binding a value the column's enum may not allow.
1519        if ($propertyType !== null && enum_exists($propertyType) && $value instanceof \UnitEnum && !is_a($value, $propertyType, true)) {
1520            throw new \InvalidArgumentException(
1521                'Column [' . $bare . '] expects an enum value of type ['
1522                . $propertyType . ']; got ' . get_debug_type($value) . '.'
1523            );
1524        }
1525
1526        return $mapping->column->encode($value, $propertyType);
1527    }
1528
1529    // ---- Aggregates (decoded like every other scalar read) ----
1530
1531    /**
1532     * Count the matching rows.
1533     *
1534     * @return int
1535     */
1536    public function count(): int
1537    {
1538        return (int) $this->value(Aggregate::count());
1539    }
1540
1541    /**
1542     * The maximum value of a column, decoded through the cast for
1543     * declared columns.
1544     *
1545     * @param  string  $column
1546     * @return mixed
1547     */
1548    public function max(string $column): mixed
1549    {
1550        return $this->value(Aggregate::max($column));
1551    }
1552
1553    /**
1554     * The minimum value of a column, decoded through the cast for
1555     * declared columns.
1556     *
1557     * @param  string  $column
1558     * @return mixed
1559     */
1560    public function min(string $column): mixed
1561    {
1562        return $this->value(Aggregate::min($column));
1563    }
1564
1565    /**
1566     * The sum of a column's values, decoded through the cast for
1567     * declared columns.
1568     *
1569     * @param  string  $column
1570     * @return mixed
1571     */
1572    public function sum(string $column): mixed
1573    {
1574        return $this->value(Aggregate::sum($column));
1575    }
1576
1577    /**
1578     * The average of a column's values, decoded through the cast for
1579     * declared columns.
1580     *
1581     * @param  string  $column
1582     * @return mixed
1583     */
1584    public function avg(string $column): mixed
1585    {
1586        return $this->value(Aggregate::avg($column));
1587    }
1588
1589    /**
1590     * Multiple aggregates in one query, decoded through each aggregate's
1591     * column cast.
1592     *
1593     * The aggregate's own alias names its result column:
1594     *
1595     *     User::query()->aggregates(
1596     *         Aggregate::count('*', 'total'),
1597     *         Aggregate::max('signed_up_at', 'latest'),
1598     *     )->latest;
1599     *
1600     * @param  Aggregate  ...$aggregates
1601     * @return \stdClass
1602     */
1603    public function aggregates(Aggregate ...$aggregates): \stdClass
1604    {
1605        // Raw rows â€” a hydrated Model has no aggregate-alias properties to
1606        // read the values back from. The aggregate select rides the scoped
1607        // clone: this builder's own column list is untouched. The spread
1608        // forwards the variadic list directly.
1609        $row = $this->scopedFor(...$aggregates)->getRaw()->first();
1610
1611        $out = new \stdClass();
1612
1613        foreach ($aggregates as $aggregate) {
1614            $column = $aggregate->column;
1615            $isExpression = $column instanceof Expression;
1616            $columnKey = $isExpression ? $column->value : $column;
1617            $key = $aggregate->alias ?? "{$aggregate->function}({$columnKey})";
1618
1619            // An Expression argument is a computed value â€” no cast applies;
1620            // declared-column arguments decode through the column's cast.
1621            $out->{$key} = $isExpression
1622                ? ($row === null ? null : $row->{$key})
1623                : $this->decodeScalar($column, $row === null ? null : $row->{$key});
1624        }
1625
1626        return $out;
1627    }
1628
1629    /**
1630     * Run one aggregate per group of the matching rows â€” a grouped
1631     * aggregate in a single query.
1632     *
1633     * The result is keyed by the group column's value, so the aggregate's
1634     * own alias is ignored here (it matters only for the multi-aggregate
1635     * row shape of aggregates()). Declared columns decode through the
1636     * column's cast; `count` is always an int.
1637     *
1638     * @param  Aggregate  $aggregate  The aggregate to compute per group.
1639     * @param  string  $groupBy  The column whose values key the result.
1640     * @return BaseCollection<string, mixed>
1641     */
1642    public function aggregateBy(Aggregate $aggregate, string $groupBy): BaseCollection
1643    {
1644        $rows = $this->scopedFor($groupBy, new Aggregate($aggregate->function, $aggregate->column, self::AGGREGATE_ALIAS))
1645            ->groupBy($groupBy)
1646            ->getRaw();
1647
1648        $out = [];
1649
1650        foreach ($rows as $row) {
1651            $key = (string) $row->{$groupBy};
1652            $raw = $row->{self::AGGREGATE_ALIAS};
1653
1654            $out[$key] = $aggregate->function === 'count'
1655                ? (int) $raw
1656                : $this->decodeAggregateColumn($aggregate->column, $raw);
1657        }
1658
1659        /** @var BaseCollection<string, mixed> */
1660        return BaseCollection::make($out);
1661    }
1662
1663    /**
1664     * Count the matching rows per group of a column â€” in a single query.
1665     *
1666     * The result is keyed by the group column's value with int counts.
1667     *
1668     * The optional seed lists group values that must appear even when the
1669     * database has no rows for them â€” each seeded key absent from the
1670     * result becomes 0. The seed is ADDITIVE: database rows always win,
1671     * and group values found in the data but missing from the seed still
1672     * appear. (Only counts can be seeded â€” an absent group has no honest
1673     * min, max, or average.)
1674     *
1675     * @param  string  $column  The column whose values key the result.
1676     * @param  list<int|string>|null  $seed  Group values guaranteed to appear (0 when absent).
1677     * @return BaseCollection<string, int>
1678     */
1679    public function countBy(string $column, ?array $seed = null): BaseCollection
1680    {
1681        /** @var BaseCollection<string, int> $counts */
1682        $counts = $this->aggregateBy(Aggregate::count('*'), $column);
1683
1684        if ($seed !== null) {
1685            $out = $counts->all();
1686
1687            foreach ($seed as $value) {
1688                $key = (string) $value;
1689                $out[$key] ??= 0;
1690            }
1691
1692            /** @var BaseCollection<string, int> */
1693            return BaseCollection::make($out);
1694        }
1695
1696        return $counts;
1697    }
1698
1699    /**
1700     * Decode one grouped-aggregate value when the aggregated column is a
1701     * declared model column.
1702     *
1703     * Mirrors the scalar decode: declared columns decode through the
1704     * column's cast, everything else (raw SQL, Expression arguments,
1705     * computed values) passes through raw.
1706     *
1707     * @param  string|Expression  $column
1708     * @param  mixed  $raw
1709     * @return mixed
1710     */
1711    private function decodeAggregateColumn(string|Expression $column, mixed $raw): mixed
1712    {
1713        if ($column instanceof Expression || $raw === null) {
1714            return $raw;
1715        }
1716
1717        return $this->decodeScalar($column, $raw);
1718    }
1719
1720    /**
1721     * Constrain the query to a primary-key value.
1722     *
1723     * Accepts a scalar (the single-PK form), a column => value map (the
1724     * composite-key form), or a list of either (the batching form, match
1725     * ANY).
1726     *
1727     * @param  KeyValue|list<KeyValue>  $id
1728     * @return static
1729     * @throws \InvalidArgumentException
1730     */
1731    public function whereKey(int|string|null|array $id): static
1732    {
1733        $primaryKeys = MetadataFactory::for($this->modelClass)->primaryKeys;
1734
1735        // PK-selection guard: whereKey's results feed save()/delete()
1736        // (getKeyForRefresh()), which need the PK hydrated. A caller-owned
1737        // select that omits the PK column hydrates models whose key
1738        // property is uninitialized â€” the key reads as null and the write
1739        // silently targets `WHERE pk IS NULL` (matching nothing, or worse).
1740        // Fail fast at the API boundary instead.
1741        $selected = $this->getColumns();
1742        if ($selected !== ['*']) {
1743            $pkNames = array_filter(
1744                array_map(fn($pk) => $pk->name, $primaryKeys),
1745                fn($name) => $name !== null,
1746            );
1747
1748            foreach ($selected as $column) {
1749                if ($column instanceof Expression) {
1750                    continue; // raw expressions carry no column contract.
1751                }
1752                if ($column instanceof Aggregate) {
1753                    continue; // aggregates are computed columns, not the PK.
1754                }
1755                if ($column instanceof SubquerySelect) {
1756                    continue; // a scalar subquery column is not the PK.
1757                }
1758                $bare = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column));
1759
1760                if ($bare === $this->table . '.*') {
1761                    $pkNames = [];
1762                    break;
1763                }
1764
1765                // Match the PK bare (`id`) or qualified (`table.id` â€” the
1766                // MTI builder's own default select qualifies every column).
1767                foreach ($pkNames as $pkName) {
1768                    if ($bare === $pkName || $bare === $this->table . '.' . $pkName) {
1769                        $pkNames = [];
1770                        break 2;
1771                    }
1772                }
1773            }
1774
1775            if ($pkNames !== []) {
1776                throw new \InvalidArgumentException(
1777                    'whereKey() requires the primary key in the select list â€” the result feeds '
1778                        . 'save()/delete(), which need the key hydrated. Add the PK column '
1779                        . '[' . implode(', ', array_map(
1780                            fn($name) => $this->table . '.' . $name,
1781                            $pkNames,
1782                        )) . '] to the select, or use select(\'' . $this->table . '.*\').'
1783                );
1784            }
1785        }
1786
1787        // A LIST of key values (scalars or key maps) constrains to ANY of
1788        // them â€” the batching path used by Collection::fresh(). An
1789        // associative map (string keys) is a composite key; a list (int
1790        // keys) is a key set. An empty list matches nothing (1 = 0).
1791        //
1792        // Each key becomes its own nested AND-group ORed at the edges
1793        // (mirroring the eager-load OR-of-groups shape): `pk = 1 OR
1794        // (a = ? AND b = ?) OR pk = 3`. Flattening would let one key's
1795        // parts AND against the NEXT key.
1796        //
1797        // The whole OR-of-groups lands INSIDE one outer AND-group: the
1798        // key set is ONE constraint unit. The constructor auto-applies
1799        // trait scopes (e.g. soft-delete `deleted_at IS NULL`) as leading
1800        // AND-groups â€” flat top-level ORs would compile to
1801        // `(scope) OR (pk = 1) OR ...` and let a scope-excluded row back
1802        // in whenever its key matched. Grouped, the scope ANDs against
1803        // the whole set: `(scope) AND ((pk = 1) OR (pk = 2) OR ...)`.
1804        //
1805        // No chunking: whereKey() returns ONE builder, so every key
1806        // compiles into the same statement regardless of how the loop is
1807        // sliced â€” chunking the loop cannot bound the statement. Splitting
1808        // the keys across SEPARATE top-level groups would AND the chunks
1809        // together (a row would need a key in EVERY chunk to match), so
1810        // the only correct shape is one group holding all the keys. An
1811        // oversized list therefore hits the driver's own placeholder cap
1812        // (SQLite's 999 variables, MySQL's max_allowed_packet) with the
1813        // driver's error â€” the same exposure every whereIn([...]) has.
1814        if (is_array($id) && array_is_list($id)) {
1815            if ($id === []) {
1816                return $this->whereRaw('1 = 0');
1817            }
1818
1819            return $this->whereNested(
1820                function (WhereBuilder $nested) use ($id): WhereBuilder {
1821                    $grouped = $nested;
1822
1823                    foreach ($id as $key) {
1824                        $grouped = $grouped->orWhereNested(
1825                            fn (WhereBuilder $keyGroup): WhereBuilder => $this->applyWhereKeyOn($keyGroup, $key)
1826                        );
1827                    }
1828
1829                    return $grouped;
1830                }
1831            );
1832        }
1833
1834        // Composite PK â†’ accept an associative array of column => value.
1835        // Every column MUST be a declared PK column (a typo'd column would
1836        // otherwise silently match nothing), and each where is qualified
1837        // for MTI (the PK exists on EVERY joined table â€” a bare column
1838        // would compile to an ambiguous-column error).
1839        //
1840        // The key map lands inside a whereNested GROUP â€” the tuple is ONE
1841        // constraint unit. Flat, a caller's later `->orWhere(...)` would OR
1842        // against the tuple's PARTS ((pk1 = ? AND pk2 = ?) OR x â€” matching
1843        // the wrong rows); grouped, the parts AND within the parens and the
1844        // caller's OR stays at the constraint's edges.
1845        if (is_array($id)) {
1846            return $this->whereNested(
1847                fn (WhereBuilder $nested): WhereBuilder => $this->applyWhereKeyOn($nested, $id)
1848            );
1849        }
1850
1851        if (count($primaryKeys) !== 1 || $primaryKeys[0]->name === null) {
1852            throw new \InvalidArgumentException(
1853                "Model [{$this->modelClass}] has a composite PK; pass an array of column => value."
1854            );
1855        }
1856
1857        $pkName = $primaryKeys[0]->name;
1858
1859        // MTI: the PK exists on EVERY joined table â€” qualify to avoid an
1860        // ambiguous-column error in the compiled SQL. The qualified form
1861        // validates through validateColumn's partition branch.
1862        if ($this->partitions !== []) {
1863            return $this->where(
1864                ($this->partitions[$pkName] ?? $this->table) . '.' . $pkName,
1865                WhereOperator::Eq,
1866                $id,
1867            );
1868        }
1869
1870        return $this->where($pkName, WhereOperator::Eq, $id);
1871    }
1872
1873    /**
1874     * Apply ONE key value onto a where-group â€” the shared body of
1875     * {@see whereKey()}'s single and list branches.
1876     *
1877     * @param  WhereBuilder  $nested
1878     * @param  mixed  $key  The scalar key value or column => value map.
1879     * @return WhereBuilder
1880     * @throws \InvalidArgumentException
1881     */
1882    private function applyWhereKeyOn(WhereBuilder $nested, mixed $key): WhereBuilder
1883    {
1884        $primaryKeys = MetadataFactory::for($this->modelClass)->primaryKeys;
1885
1886        if (is_array($key) && $key !== []) {
1887            foreach ($key as $column => $value) {
1888                $validated = $this->assertCompositeKeyValue($column, $value);
1889
1890                $this->assertCompositeKeyColumn($validated['column'], $primaryKeys);
1891
1892                $qualified = $validated['column'];
1893
1894                if ($this->partitions !== []) {
1895                    $qualified = ($this->partitions[$qualified] ?? $this->table) . '.' . $qualified;
1896                }
1897
1898                $nested = $nested->where($qualified, WhereOperator::Eq, $validated['value']);
1899            }
1900
1901            return $nested;
1902        }
1903
1904        // Runtime boundary: `$key` is a list ELEMENT â€” the KeyValue scalar
1905        // contract is PHPDoc-only, so an untyped caller can pass anything.
1906        // The native whereKey() union already TypeErrors at the public
1907        // boundary; this guard covers the mixed path behind it.
1908        if (!is_int($key) && !is_string($key) && $key !== null) {
1909            throw new \InvalidArgumentException(
1910                'A single primary-key value must be int, string or null; got ' . get_debug_type($key) . '.'
1911            );
1912        }
1913
1914        $single = $key;
1915
1916        if (count($primaryKeys) !== 1 || $primaryKeys[0]->name === null) {
1917            throw new \InvalidArgumentException(
1918                "Model [{$this->modelClass}] has a composite PK; pass an array of column => value."
1919            );
1920        }
1921
1922        $pkName = $primaryKeys[0]->name;
1923
1924        if ($this->partitions !== []) {
1925            $pkName = ($this->partitions[$pkName] ?? $this->table) . '.' . $pkName;
1926        }
1927
1928        return $nested->where($pkName, WhereOperator::Eq, $single);
1929    }
1930
1931    /**
1932     * Validate one composite-key entry and its value.
1933     *
1934     * @param  mixed  $column  Must be a non-empty string naming a declared PK column.
1935     * @param  mixed  $value  Must be int, string or null.
1936     * @return array{column: string, value: int|string|null}
1937     * @throws \InvalidArgumentException
1938     */
1939    private function assertCompositeKeyValue(mixed $column, mixed $value): array
1940    {
1941        if (!is_string($column) || $column === '') {
1942            throw new \InvalidArgumentException(
1943                'A composite key must be a column => value map with string column names; got '
1944                    . (is_string($column) ? 'an empty column name' : get_debug_type($column)) . '.'
1945            );
1946        }
1947
1948        if (!is_int($value) && !is_string($value) && $value !== null) {
1949            throw new \InvalidArgumentException(
1950                "Composite key value for [{$column}] must be int, string or null; got "
1951                    . get_debug_type($value) . '.'
1952            );
1953        }
1954
1955        return ['column' => $column, 'value' => $value];
1956    }
1957
1958    /**
1959     * Assert a composite-key column is one of the model's primary keys.
1960     *
1961     * @param  string  $column
1962     * @param  list<Column>  $primaryKeys
1963     * @return void
1964     * @throws \InvalidArgumentException
1965     */
1966    private function assertCompositeKeyColumn(string $column, array $primaryKeys): void
1967    {
1968        foreach ($primaryKeys as $primaryKey) {
1969            if ($primaryKey->name === $column) {
1970                return;
1971            }
1972        }
1973
1974        throw new \InvalidArgumentException(
1975            "Composite key column [{$column}] is not a primary key of model "
1976                . "[{$this->modelClass}]; expected one of: "
1977                . implode(', ', array_map(fn(Column $pk) => $pk->name ?? '(unnamed)', $primaryKeys)) . '.'
1978        );
1979    }
1980
1981    // ---- Model-aware overrides ----
1982
1983    /**
1984     * Select columns, mapping `*` to the model's own columns.
1985     *
1986     * The PK columns and the soft-delete column (when the model uses
1987     * {@see SoftDeletes}) are always present so hydration, `whereKey()`
1988     * and the trash-state reads work. An {@see Expression} bypasses
1989     * validation â€” raw SQL by contract.
1990     *
1991     * When the builder already JOINs another table, every bare spec is
1992     * QUALIFIED to this model's table (`table.column`) â€” unqualified
1993     * names would compile to ambiguous-column SQL once a second table
1994     * carries the same name. The expansion, the caller-owned passthrough
1995     * and the forced-key merge all ride the qualification, so ordering is
1996     * irrelevant: `select('*')` before or after `join()` lands on the
1997     * same qualified list.
1998     *
1999     * @param  string|Expression|Aggregate|SubquerySelect  ...$columns  Each column as its own argument, or none to reset to `*`.
2000     * @return static
2001     * @throws \InvalidArgumentException
2002     */
2003    public function select(string|Expression|Aggregate|SubquerySelect ...$columns): static
2004    {
2005        $flat = $columns === [] ? ['*'] : array_values($columns);
2006
2007        if ($flat === ['*']) {
2008            // `*` â†’ the model's own columns (PK first, then the rest).
2009            $flat = array_values(array_unique(array_merge(
2010                $this->forcedKeys,
2011                array_diff($this->modelColumns, $this->forcedKeys),
2012            )));
2013        } else {
2014            foreach ($flat as $column) {
2015                if ($column instanceof Aggregate) {
2016                    // The aggregate's column gets the same allowlist check
2017                    // as a plain select column; an Expression argument is
2018                    // raw SQL by contract and passes through.
2019                    if (!$column->column instanceof Expression && $column->column !== '*') {
2020                        $this->validateColumn($column->column);
2021                    }
2022                } elseif ($column instanceof SubquerySelect) {
2023                    // The node's sub-builder validates its columns
2024                    // against its own model; the alias was validated by
2025                    // the constructor. No outer validation applies.
2026                } elseif (!$column instanceof Expression) {
2027                    $this->validateColumn($column);
2028                }
2029            }
2030
2031            // An explicit list containing QUALIFIED specs (or `table.*`)
2032            // is caller-owned â€” typically a joined read where a bare PK
2033            // would be ambiguous. No forced-key merge. Every string spec has
2034            // been allowlist-validated above (validateColumn's qualified
2035            // branch checks the partition map / own table / joined tables),
2036            // so a typo'd or attacker-influenced `table.column` fails fast
2037            // here rather than compiling into the SQL quote-only.
2038            $callerOwned = (bool) array_filter(
2039                $flat,
2040                fn(string|Expression|Aggregate|SubquerySelect $column) => !is_string($column)
2041                    ? true
2042                    : str_contains($column, '.'),
2043            );
2044
2045            if ($callerOwned) {
2046                // Order-stable qualification: each spec qualifies in its
2047                // own position â€” bare strings map to `table.col`, objects
2048                // (Aggregate/Expression/SubquerySelect) pass through
2049                // verbatim â€” so the compiled list matches the caller's
2050                // written order.
2051                return parent::select(...array_map(
2052                    fn(string|Expression|Aggregate|SubquerySelect $column) => is_string($column)
2053                        ? $this->qualifyForJoin([$column])[0]
2054                        : $column,
2055                    $flat,
2056                ));
2057            }
2058        }
2059
2060        if ($this->getGroups() !== []) {
2061            return parent::select(...array_map(
2062                fn(string|Expression|Aggregate|SubquerySelect $column) => is_string($column)
2063                    ? $this->qualifyForJoin([$column])[0]
2064                    : $column,
2065                $flat,
2066            ));
2067        }
2068
2069        // Merge forced keys (PK always selected), dedupe, preserve order.
2070        // At this point every entry is a validated string column (all
2071        // Expression entries exited via the caller-owned branch above â€” and
2072        // an aggregate query with an Expression select skips the merge too,
2073        // since grouping changes the shape). Filter defensively for the
2074        // type system: array_unique/array_merge need strings here.
2075        $stringColumns = array_values(array_filter(
2076            $flat,
2077            fn(string|Expression|Aggregate|SubquerySelect $column) => is_string($column),
2078        ));
2079        $merged = array_values(array_unique(array_merge($this->forcedKeys, $stringColumns)));
2080
2081        return parent::select(...$this->qualifyForJoin($merged));
2082    }
2083
2084    /**
2085     * Qualify bare column specs when the query joins another table.
2086     *
2087     * A bare name compiles unqualified â€” unambiguous for a single-table
2088     * query, but AMBIGUOUS once the join adds a second table carrying the
2089     * same column (`id` above all). Every string spec gets this model's
2090     * table prefix; already-qualified specs, Aggregate and Expression
2091     * entries pass through untouched. No-op when nothing is joined â€” bare
2092     * names stay the join-less ergonomic default.
2093     *
2094     * @param  list<string>  $columns
2095     * @return list<string>
2096     */
2097    private function qualifyForJoin(array $columns): array
2098    {
2099        if ($this->joins === []) {
2100            return $columns;
2101        }
2102
2103        $qualified = [];
2104
2105        foreach ($columns as $column) {
2106            $qualified[] = str_contains($column, '.')
2107                ? $column
2108                : $this->table . '.' . $column;
2109        }
2110
2111        return $qualified;
2112    }
2113
2114    /**
2115     * Add a where clause with model-aware column validation and cast
2116     * encoding.
2117     *
2118     * Every where value encodes through the validated column's cast
2119     * ({@see Column::encode()}) â€” the query-path twin of the write path:
2120     * an enum case binds its backing value (a unit case, its name), a
2121     * `Carbon`/DateTime binds the column's datetime or date form, an int
2122     * timestamp binds the datetime string, a Json column accepts arrays
2123     * and JsonStorable objects. Valid raw values pass through unchanged,
2124     * so hosts passing backing values decoded from request bodies keep
2125     * working, and an INVALID raw value ('bogus' against an enum column,
2126     * a non-uuid on a Uuid column) fails fast naming the column instead
2127     * of silently matching nothing. Two shapes bypass encoding by
2128     * contract: a LIKE pattern (a match template, not a cell value â€”
2129     * `'%og%'` is not an enum backing value) and raw SQL expressions.
2130     *
2131     * @param  string|Expression  $column
2132     * @param  WhereOperator|string  $operator
2133     * @param  mixed  $value
2134     * @param  WhereBoolean  $boolean
2135     * @return static
2136     * @throws \InvalidArgumentException
2137     */
2138    public function where(
2139        string|Expression $column,
2140        WhereOperator|string $operator,
2141        mixed $value,
2142        WhereBoolean $boolean = WhereBoolean::And,
2143    ): static {
2144        if ($column instanceof Expression) {
2145            return parent::where($column, $operator, $value, $boolean);
2146        }
2147
2148        $this->validateColumn($column);
2149
2150        // Resolve the operator FIRST: list encoding is gated on the
2151        // operators that legitimately take lists, so an illegal shape
2152        // (a nested array under a comparison operator) reaches
2153        // parent::where() UN-encoded and fails with its accurate
2154        // declaration error, not a spurious cast error.
2155        $resolved = $operator instanceof WhereOperator ? $operator : WhereOperator::fromChecked($operator);
2156
2157        $listShaped = $resolved === WhereOperator::In
2158            || $resolved === WhereOperator::NotIn
2159            || $resolved === WhereOperator::Between
2160            || $resolved === WhereOperator::NotBetween;
2161
2162        // The list shapes (IN/NOT IN/BETWEEN) encode element-wise; every
2163        // other operator carries at most one bindable value. Null and raw
2164        // SQL expressions pass through untouched (the null-vs-comparison
2165        // guard lives in parent::where()), and an ARRAY under a
2166        // non-list operator passes through too â€” parent::where() owns
2167        // that declaration error.
2168        $encoded = match (true) {
2169            $listShaped && is_array($value) => array_map(
2170                fn($item) => $this->encodeWhereValue($column, $item),
2171                $value,
2172            ),
2173            is_array($value) => $value,
2174            default => $this->encodeWhereValue($column, $value, $resolved),
2175        };
2176
2177        return parent::where($column, $resolved, $encoded, $boolean);
2178    }
2179
2180    /**
2181     * Add an order-by clause with model-aware column validation.
2182     *
2183     * A bare column QUALIFIES when the query joins another table â€” the
2184     * same join-aware rule the select list follows (through relations
2185     * order by the related model's bare PK; unqualified, both joined
2186     * tables carry `id` and the driver rejects the ambiguity).
2187     *
2188     * @param  string|Expression  $column
2189     * @param  SortDirection|string  $direction
2190     * @return static
2191     * @throws \InvalidArgumentException
2192     */
2193    public function orderBy(string|Expression $column, SortDirection|string $direction = SortDirection::Asc): static
2194    {
2195        if (!$column instanceof Expression) {
2196            $this->validateColumn($column);
2197
2198            $qualified = $this->qualifyForJoin([$column]);
2199
2200            return parent::orderBy($qualified[0], $direction);
2201        }
2202
2203        return parent::orderBy($column, $direction);
2204    }
2205
2206    /**
2207     * Group by columns with model-aware column validation.
2208     *
2209     * @param  string|array<int, string>  $columns
2210     * @return static
2211     * @throws \InvalidArgumentException
2212     */
2213    public function groupBy(string|array $columns): static
2214    {
2215        foreach (is_array($columns) ? $columns : [$columns] as $column) {
2216            $this->validateColumn($column);
2217        }
2218
2219        return parent::groupBy($columns);
2220    }
2221
2222    /**
2223     * Add a having clause with model-aware validation and cast encoding.
2224     *
2225     * The comparison value encodes through the compared column's cast â€”
2226     * an enum case filters a grouped enum column by its backing value, a
2227     * datetime by its stored form. An {@see Aggregate} encodes by its
2228     * INNER column (`max('level')` compares a level-cell value, exactly
2229     * how the aggregate decode reads the inner column); an {@see Expression}
2230     * is raw SQL by contract and skips both validation and encoding.
2231     *
2232     * @param  string|Expression|Aggregate  $column
2233     * @param  WhereOperator|string  $operator
2234     * @param  mixed  $value
2235     * @return static
2236     * @throws \InvalidArgumentException
2237     */
2238    public function having(string|Expression|Aggregate $column, WhereOperator|string $operator, mixed $value): static
2239    {
2240        if ($column instanceof Aggregate) {
2241            // The column of a typed aggregate gets the same allowlist check
2242            // as a plain column â€” the old string path bypassed it. An
2243            // Expression argument is raw SQL by contract.
2244            if (!$column->column instanceof Expression && $column->column !== '*') {
2245                $this->validateColumn($column->column);
2246
2247                $value = $this->encodeWhereValue($column->column, $value, $operator);
2248            }
2249
2250            return parent::having($column, $operator, $value);
2251        }
2252
2253        if (!$column instanceof Expression) {
2254            $this->validateColumn($column);
2255            $value = $this->encodeWhereValue($column, $value, $operator);
2256        }
2257
2258        return parent::having($column, $operator, $value);
2259    }
2260
2261    /**
2262     * Fail fast on an unknown model column.
2263     *
2264     * @param  string  $column
2265     * @return void
2266     * @throws \InvalidArgumentException
2267     */
2268    protected function validateColumn(string $column): void
2269    {
2270        // Hash-set lookups, not linear scans: every where/orderBy/groupBy/
2271        // having/select validates, so O(clauses Ã— columns) list scans on
2272        // clause-heavy queries against wide models collapse to O(1) each.
2273        // The sets are immutable per builder â€” built lazily once.
2274        $columns = $this->columnSet ??= array_fill_keys($this->modelColumns, true);
2275        $forced = $this->forcedKeySet ??= array_fill_keys($this->forcedKeys, true);
2276
2277        if (isset($columns[$column]) || isset($forced[$column])) {
2278            return;
2279        }
2280
2281        // Strip a trailing `as alias` â€” the spec validates by its SOURCE
2282        // reference; the alias only names the result key (the Grammar owns
2283        // the AS rendering).
2284        $source = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column));
2285
2286        if ($source !== $column && isset($columns[$source])) {
2287            return; // `column as alias` over a declared column.
2288        }
2289
2290        if ($source === '*') {
2291            return; // a raw star â€” it qualifies IN PLACE under a join (table.*).
2292        }
2293
2294        // Qualified reference â€” `table.column[ as alias]`. The qualified
2295        // form names its table explicitly: the column must exist on the
2296        // NAMED table per the partition map (MTI), on a table this query
2297        // JOINs (the through-relation select spec), or on the builder's
2298        // OWN table â€” and in the own-table case the COLUMN part must still
2299        // be a declared model column (or `*`). Without that check a
2300        // `table.bogus` spec slipped through where the plain `bogus` form
2301        // would have failed fast â€” quoting prevents injection, but the
2302        // model layer's allowlist contract was not uniformly applied.
2303        if (str_contains($source, '.')) {
2304            [$table, $rest] = explode('.', $source, 2);
2305
2306            // A join spec may carry an alias (`bp_users as other`): the
2307            // alias, the source table, and the verbatim spec all address
2308            // the joined table in ON conditions.
2309            $joinTables = [];
2310            foreach ($this->joins as $join) {
2311                $joinTables[] = $join['table'];
2312                if (preg_match('/^(.*?)\s+as\s+(\S+)$/i', $join['table'], $m) === 1) {
2313                    $joinTables[] = trim($m[1]);
2314                    $joinTables[] = $m[2];
2315                }
2316            }
2317
2318            if (($this->partitions !== [] && ($this->partitions[$rest] ?? null) === $table)
2319                || in_array($table, $joinTables, true)
2320            ) {
2321                return;
2322            }
2323
2324            // Correlation context: a table the OUTER query names (its own
2325            // table, its join tables and aliases) when this builder is a
2326            // whereExists subquery â€” the correlation references those
2327            // tables by design. Their column allowlist belongs to the
2328            // OUTER model, not this one, so admission here is by table
2329            // only; a typo in the outer table's column is the outer
2330            // builder's contract to catch (and SQL fails fast on an
2331            // unknown column regardless).
2332            if (in_array($table, $this->correlationTables, true)) {
2333                return;
2334            }
2335
2336            if ($table === $this->table) {
2337                if ($rest === '*' || isset($columns[$rest]) || isset($forced[$rest])) {
2338                    return;
2339                }
2340                // Own-table prefix but an unknown column â€” fall through to
2341                // the throw below, same as the unqualified form would.
2342            }
2343        }
2344
2345        throw new \InvalidArgumentException(
2346            "Unknown column [{$column}] on model [{$this->modelClass}]."
2347        );
2348    }
2349
2350    // ---- Writes (validated + encoded through the column casts) ----
2351
2352    /**
2353     * Insert rows with model-aware validation, cast encoding, and bulk
2354     * row hooks.
2355     *
2356     * @param  array<string, mixed>|list<array<string, mixed>>  $values
2357     * @return int
2358     * @throws \InvalidArgumentException
2359     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
2360     */
2361    public function insert(array $values): int
2362    {
2363        $rows = $this->normalizeRows($values);
2364
2365        Model::dispatchInsertHooks($this->modelClass, $rows);
2366
2367        return parent::insert($this->encodeRows($rows));
2368    }
2369
2370    /**
2371     * Insert a single row and return the generated id, validated and
2372     * encoded like {@see insert()}.
2373     *
2374     * @param  array<string, mixed>  $values
2375     * @return string|int|null
2376     * @throws \InvalidArgumentException
2377     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
2378     */
2379    public function insertGetId(array $values): string|int|null
2380    {
2381        $rows = [$values];
2382
2383        Model::dispatchInsertHooks($this->modelClass, $rows);
2384
2385        return parent::insertGetId($this->encodeRow($rows[0]));
2386    }
2387
2388    /**
2389     * Update the matching rows with model-aware validation, cast
2390     * encoding, and bulk update hooks.
2391     *
2392     * @param  array<string, mixed>  $values
2393     * @return int
2394     * @throws \InvalidArgumentException
2395     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
2396     */
2397    public function update(array $values): int
2398    {
2399        Model::dispatchUpdateHooks($this->modelClass, $values);
2400
2401        return parent::update($this->encodeRow($values));
2402    }
2403
2404    /**
2405     * Normalize an insert payload to a row list.
2406     *
2407     * A single map is a one-row batch â€” the normalization makes the hook
2408     * contract uniform (insert hooks always see a row list) and keeps
2409     * {@see encodeRows()} on the single list path.
2410     *
2411     * @param  array<string, mixed>|list<array<int|string, mixed>>  $values
2412     * @return list<array<int|string, mixed>>
2413     */
2414    private function normalizeRows(array $values): array
2415    {
2416        if (array_is_list($values)) {
2417            /** @var list<array<int|string, mixed>> */
2418            return $values;
2419        }
2420
2421        return [$values];
2422    }
2423
2424    /**
2425     * Encode a row list through the column casts.
2426     *
2427     * @param  list<array<int|string, mixed>>  $rows
2428     * @return list<array<string, mixed>>
2429     * @throws \InvalidArgumentException
2430     */
2431    private function encodeRows(array $rows): array
2432    {
2433        // A list whose entries are NOT arrays is caller error â€” a list of
2434        // scalars (`insert(['name', 'age'])`) is never a valid row set.
2435        // Delegating each entry to encodeRow() makes that fail fast: its
2436        // native `array` parameter TypeErrors on a scalar, where a
2437        // duplicated foreach would only WARN and silently encode empty
2438        // rows (PHP 8 foreach-over-string skips the loop).
2439        $encoded = [];
2440
2441        foreach ($rows as $row) {
2442            $encoded[] = $this->encodeRow($row);
2443        }
2444
2445        return $encoded;
2446    }
2447
2448    /**
2449     * Encode one row map â€” the shared body of {@see encodeRows()}.
2450     *
2451     * @param  array<int|string, mixed>  $row
2452     * @return array<string, mixed>
2453     * @throws \InvalidArgumentException
2454     */
2455    private function encodeRow(array $row): array
2456    {
2457        $encoded = [];
2458
2459        foreach ($row as $column => $value) {
2460            $name = (string) $column;
2461            $encoded[$name] = $this->encodeValue($name, $value);
2462        }
2463
2464        return $encoded;
2465    }
2466
2467    /**
2468     * Validate one write-path column key and encode its value.
2469     *
2470     * @param  string  $column
2471     * @param  mixed  $value
2472     * @return mixed
2473     * @throws \InvalidArgumentException
2474     */
2475    private function encodeValue(string $column, mixed $value): mixed
2476    {
2477        $this->validateWriteColumn($column);
2478
2479        $mapping = MetadataFactory::for($this->modelClass)->mappingFor($column);
2480
2481        return $mapping->column->encode($value, $mapping->propertyType);
2482    }
2483
2484    /**
2485     * Validate one write-path column key.
2486     *
2487     * @param  string  $column
2488     * @return void
2489     * @throws \InvalidArgumentException
2490     */
2491    private function validateWriteColumn(string $column): void
2492    {
2493        if (!MetadataFactory::for($this->modelClass)->hasColumn($column)) {
2494            throw new \InvalidArgumentException(
2495                "Unknown column [{$column}] on model [{$this->modelClass}]."
2496            );
2497        }
2498    }
2499}

Inherited from BlueprintAU\Radiant\Database\Query\QueryBuilder

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    }
246    final public function distinct(): static
247    {
248        $clone = clone $this;
249        $clone->distinct = true;
250        return $clone;
251    }
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    }
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    }
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    }
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    }
330    final public function crossJoin(string $table): static
331    {
332        return $this->addJoin(JoinType::Cross, $table, '', '=', '');
333    }
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    }
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    }
646    public function whereNotExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static
647    {
648        return $this->whereExists($query, $boolean, true);
649    }
657    public function orWhereExists(QueryBuilder $query): static
658    {
659        return $this->whereExists($query, WhereBoolean::Or);
660    }
668    public function orWhereNotExists(QueryBuilder $query): static
669    {
670        return $this->whereExists($query, WhereBoolean::Or, true);
671    }
701    public function whereNotInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static
702    {
703        return $this->whereInQuery($column, $query, $boolean, true);
704    }
713    public function orWhereInQuery(string $column, QueryBuilder $query): static
714    {
715        return $this->whereInQuery($column, $query, WhereBoolean::Or);
716    }
725    public function orWhereNotInQuery(string $column, QueryBuilder $query): static
726    {
727        return $this->whereInQuery($column, $query, WhereBoolean::Or, true);
728    }
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    }
853    public function limit(int $limit): static
854    {
855        $clone = clone $this;
856        $clone->limit = $limit;
857        return $clone;
858    }
866    public function offset(int $offset): static
867    {
868        $clone = clone $this;
869        $clone->offset = $offset;
870        return $clone;
871    }
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    }
900    final public function lockForUpdate(): static
901    {
902        $clone = clone $this;
903        $clone->lock = LockType::Update;
904        return $clone;
905    }
912    final public function sharedLock(): static
913    {
914        $clone = clone $this;
915        $clone->lock = LockType::Shared;
916        return $clone;
917    }
1005    protected function scalarColumn(string $column): string
1006    {
1007        return (string) preg_replace('/\s+as\s+[`"]?[a-z_][a-z0-9_]*[`"]?$/i', '', $column);
1008    }
1030    final public function exists(): bool
1031    {
1032        return $this->first() !== null;
1033    }
1212    final public function delete(): int
1213    {
1214        return $this->connection->delete($this);
1215    }
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    }
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    }
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    }
1291    final public function getColumns(): array
1292    {
1293        return $this->columns;
1294    }
1301    final public function isDistinct(): bool
1302    {
1303        return $this->distinct;
1304    }
1311    final public function getFrom(): string|QueryBuilder
1312    {
1313        return $this->from;
1314    }
1321    final public function getFromAlias(): ?string
1322    {
1323        return $this->fromAlias;
1324    }
1331    final public function getJoins(): array
1332    {
1333        return $this->joins;
1334    }
1344    final public function getWheres(): array
1345    {
1346        return $this->wheres;
1347    }
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    }
1372    final public function getGroups(): array
1373    {
1374        return $this->groups;
1375    }
1382    final public function getHavings(): array
1383    {
1384        return $this->havings;
1385    }
1392    final public function getOrders(): array
1393    {
1394        return $this->orders;
1395    }
1402    final public function getUnions(): array
1403    {
1404        return $this->unions;
1405    }
1412    final public function getLock(): ?LockType
1413    {
1414        return $this->lock;
1415    }
1422    final public function getLimit(): ?int
1423    {
1424        return $this->limit;
1425    }
1432    final public function getOffset(): ?int
1433    {
1434        return $this->offset;
1435    }
1442    final public function getInsertIdColumn(): ?string
1443    {
1444        return $this->insertIdColumn;
1445    }
1452    final public function isInsertIdAutoIncrement(): bool
1453    {
1454        return $this->insertIdAutoIncrement ?? false;
1455    }