Lines 92.51% 581 / 628
Methods 84.42% 103 / 122
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 assertNotReservedPrefix 100.00% 7 / 7 100.00% 1 / 1 2
 connection 100.00% 1 / 1 100.00% 1 / 1 1
 resolveConnection 100.00% 1 / 1 100.00% 1 / 1 1
 table 100.00% 8 / 8 100.00% 1 / 1 2
 newQuery 100.00% 1 / 1 100.00% 1 / 1 1
 find 100.00% 1 / 1 100.00% 1 / 1 1
 findOrFail 100.00% 1 / 1 100.00% 1 / 1 1
 firstOrFail 100.00% 1 / 1 100.00% 1 / 1 1
 sole 100.00% 1 / 1 100.00% 1 / 1 1
 findOrCreate 100.00% 1 / 1 100.00% 1 / 1 1
 all 100.00% 1 / 1 100.00% 1 / 1 1
 first 100.00% 1 / 1 100.00% 1 / 1 1
 count 100.00% 1 / 1 100.00% 1 / 1 1
 exists 100.00% 1 / 1 100.00% 1 / 1 1
 value 100.00% 1 / 1 100.00% 1 / 1 1
 pluck 100.00% 1 / 1 100.00% 1 / 1 1
 max 100.00% 1 / 1 100.00% 1 / 1 1
 min 0.00% 0 / 1 0.00% 0 / 1 2
 sum 0.00% 0 / 1 0.00% 0 / 1 2
 avg 0.00% 0 / 1 0.00% 0 / 1 2
 aggregates 100.00% 1 / 1 100.00% 1 / 1 1
 aggregateBy 100.00% 1 / 1 100.00% 1 / 1 1
 countBy 100.00% 1 / 1 100.00% 1 / 1 1
 cursor 100.00% 1 / 1 100.00% 1 / 1 1
 where 100.00% 1 / 1 100.00% 1 / 1 1
 whereNested 100.00% 1 / 1 100.00% 1 / 1 1
 whereExists 100.00% 1 / 1 100.00% 1 / 1 1
 whereInQuery 100.00% 1 / 1 100.00% 1 / 1 1
 orderBy 100.00% 1 / 1 100.00% 1 / 1 1
 limit 100.00% 1 / 1 100.00% 1 / 1 1
 offset 100.00% 1 / 1 100.00% 1 / 1 1
 select 100.00% 1 / 1 100.00% 1 / 1 1
 groupBy 100.00% 1 / 1 100.00% 1 / 1 1
 having 100.00% 1 / 1 100.00% 1 / 1 1
 with 100.00% 1 / 1 100.00% 1 / 1 1
 freshTimestamp 100.00% 1 / 1 100.00% 1 / 1 1
 saved 100.00% 1 / 1 100.00% 1 / 1 1
 deleted 100.00% 1 / 1 100.00% 1 / 1 1
 saving 100.00% 1 / 1 100.00% 1 / 1 1
 deleting 100.00% 1 / 1 100.00% 1 / 1 1
 restoring 100.00% 1 / 1 100.00% 1 / 1 1
 restored 100.00% 1 / 1 100.00% 1 / 1 1
 fireLifecycle 100.00% 5 / 5 100.00% 1 / 1 3
 save 100.00% 18 / 18 100.00% 1 / 1 6
 delete 100.00% 7 / 7 100.00% 1 / 1 3
 dispatchWriteHooks 57.14% 8 / 14 0.00% 0 / 1 6.97
 dispatchInsertHooks 100.00% 17 / 17 100.00% 1 / 1 4
 dispatchUpdateHooks 64.70% 11 / 17 0.00% 0 / 1 4.70
 performDelete 84.84% 28 / 33 0.00% 0 / 1 11.42
 performInsert 100.00% 17 / 17 100.00% 1 / 1 5
 materializeDefaults 100.00% 8 / 8 100.00% 1 / 1 6
 performMtiInsert 94.28% 66 / 70 0.00% 0 / 1 20.07
 rootAutoIncrement 100.00% 6 / 6 100.00% 1 / 1 3
 encodedPkValue 100.00% 4 / 4 100.00% 1 / 1 5
 setPrimaryKey 100.00% 7 / 7 100.00% 1 / 1 5
 performUpdate 100.00% 8 / 8 100.00% 1 / 1 3
 performMtiUpdate 90.00% 27 / 30 0.00% 0 / 1 9.08
 getDirty 100.00% 5 / 5 100.00% 1 / 1 4
 syncOriginal 100.00% 1 / 1 100.00% 1 / 1 1
 getKeyForRefresh 100.00% 8 / 8 100.00% 1 / 1 5
 assertKeyResolvedForWrite 100.00% 18 / 18 100.00% 1 / 1 6
 assertAssignedMtiKeyPresent 90.90% 10 / 11 0.00% 0 / 1 5.02
 newInstance 100.00% 1 / 1 100.00% 1 / 1 1
 fromRow 100.00% 16 / 16 100.00% 1 / 1 6
 pivotValue 100.00% 1 / 1 100.00% 1 / 1 1
 hydrateProperty 91.66% 11 / 12 0.00% 0 / 1 7.03
 attribute 100.00% 9 / 9 100.00% 1 / 1 5
 setAttribute 100.00% 7 / 7 100.00% 1 / 1 2
 setColumn 37.50% 3 / 8 0.00% 0 / 1 2.98
 getProperties 100.00% 1 / 1 100.00% 1 / 1 1
 getPrimaryKeys 100.00% 1 / 1 100.00% 1 / 1 1
 getColumnValues 100.00% 18 / 18 100.00% 1 / 1 6
 castForWrite 0.00% 0 / 2 0.00% 0 / 1 2
 hasMany 100.00% 6 / 6 100.00% 1 / 1 1
 hasOne 100.00% 6 / 6 100.00% 1 / 1 1
 belongsTo 100.00% 6 / 6 100.00% 1 / 1 1
 hasOneThrough 100.00% 8 / 8 100.00% 1 / 1 1
 hasManyThrough 100.00% 8 / 8 100.00% 1 / 1 1
 morphMany 100.00% 14 / 14 100.00% 1 / 1 2
 morphOne 100.00% 14 / 14 100.00% 1 / 1 2
 morphTo 100.00% 7 / 7 100.00% 1 / 1 1
 defaultMorphForeignKey 87.50% 7 / 8 0.00% 0 / 1 5.05
 defaultMorphTypeColumn 100.00% 8 / 8 100.00% 1 / 1 5
 belongsToMany 100.00% 9 / 9 100.00% 1 / 1 1
 morphToMany 100.00% 2 / 2 100.00% 1 / 1 1
 morphedByMany 100.00% 2 / 2 100.00% 1 / 1 1
 setRelation 100.00% 2 / 2 100.00% 1 / 1 1
 cachedRelation 100.00% 4 / 4 100.00% 1 / 1 3
 relationName 76.92% 10 / 13 0.00% 0 / 1 5.31
 relationLoaded 100.00% 1 / 1 100.00% 1 / 1 1
 defaultForeignKeyFor 100.00% 2 / 2 100.00% 1 / 1 1
 defaultForeignKeyFromKey 100.00% 2 / 2 100.00% 1 / 1 1
 assertDerivableKey 100.00% 6 / 6 100.00% 1 / 1 2
 defaultForeignKey 100.00% 2 / 2 100.00% 1 / 1 1
 defaultForeignKeyFrom 100.00% 2 / 2 100.00% 1 / 1 1
 defaultLocalKey 100.00% 1 / 1 100.00% 1 / 1 1
 defaultLocalKeyOf 100.00% 1 / 1 100.00% 1 / 1 1
 primaryKeyNamesOf 76.92% 10 / 13 0.00% 0 / 1 5.31
 assertMorphKeyMatches 100.00% 9 / 9 100.00% 1 / 1 2
 singlePrimaryKeyOf 100.00% 7 / 7 100.00% 1 / 1 3
 assertColumnExists 100.00% 2 / 2 100.00% 1 / 1 3
 assertSingleColumnExists 100.00% 8 / 8 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] where n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereEq 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereEq 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNested n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNestedGroup 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereNested 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereExists n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNotExists 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereExists 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereNotExists 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereInQuery n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNotInQuery 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereNotInQuery 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhere 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereIn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNotIn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNull 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNotNull 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereBetween 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNotBetween 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereLike 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orWhereLike 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] whereNotLike 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] orderBy n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] limit n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] offset n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] select n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] groupBy n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersStaticQuery] having n/a 0 / 0 n/a 0 / 0 0
31abstract class Model
32{
33    /** @use FiltersStaticQuery<Model> */
34    use FiltersStaticQuery;
35
36    /**
37     * The reserved alias prefix for the ORM's internal select aliases.
38     *
39     * Columns and pivot columns must not start with it — a user column
40     * named `radiant_foo` would collide with the internal aliases.
41     */
42    public const RESERVED_PREFIX = 'radiant_';
43
44    /**
45     * The loaded values at hydration time, keyed by column name.
46     *
47     * Values live in the encoded (bindable) space so dirty tracking
48     * compares like with like.
49     *
50     * @var array<string, mixed>
51     */
52    protected array $original = [];
53
54    /**
55     * Runtime overrides for synthetic columns — columns the metadata
56     * declares but no PHP property backs.
57     *
58     * @var array<string, mixed>
59     */
60    protected array $syntheticValues = [];
61
62    /**
63     * Whether the model exists in the database.
64     *
65     * Set by the write paths and by hydration. Public read; writes stay
66     * inside the class hierarchy.
67     *
68     * @var bool
69     */
70    public protected(set) bool $exists = false;
71
72    /**
73     * Whether the model was created by the current request.
74     *
75     * True right after a successful INSERT; false on hydration and on
76     * every other path.
77     *
78     * @var bool
79     */
80    public protected(set) bool $wasRecentlyCreated = false;
81
82    /**
83     * Loaded relation results, keyed by relation name.
84     *
85     * @var array<string, Model|Collection<int, Model>|null>
86     */
87    protected array $relations = [];
88
89    /**
90     * Lifecycle callbacks registered on this instance, keyed by event
91     * name (`saving`, `saved`, `deleting`, `deleted`, `restoring`,
92     * `restored`). Attempt listeners may return false to veto the action.
93     *
94     * @var array<string, list<callable>>
95     */
96    private array $lifecycleCallbacks = [];
97
98    /**
99     * Pivot values carried onto this model by a BelongsToMany eager load,
100     * keyed by pivot column name.
101     *
102     * @var array<string, mixed>
103     */
104    protected array $pivotValues = [];
105
106    /**
107     * Fail fast when a column name starts with the ORM's reserved prefix.
108     *
109     * @param  string  $column
110     * @param  string  $role
111     * @return void
112     * @throws \InvalidArgumentException
113     */
114    final public static function assertNotReservedPrefix(string $column, string $role): void
115    {
116        if (str_starts_with($column, self::RESERVED_PREFIX)) {
117            throw new \InvalidArgumentException(
118                "The {$role} [{$column}] starts with the reserved prefix ["
119                . self::RESERVED_PREFIX . '] — the ORM uses that namespace for its internal '
120                . 'select aliases (radiant_pivot_*, radiant_scalar, radiant_through_parent_*). '
121                . 'Rename the column.'
122            );
123        }
124    }
125
126    // ---- Connection ----
127
128    /**
129     * The default connection name for this model.
130     *
131     * @var string|null
132     */
133    protected static ?string $connection = null;
134
135    /**
136     * The connection this model runs on.
137     *
138     * @return ConnectionInterface
139     */
140    final public static function connection(): ConnectionInterface
141    {
142        return static::resolveConnection();
143    }
144
145    /**
146     * The single seam for connection resolution.
147     *
148     * @return ConnectionInterface
149     */
150    protected static function resolveConnection(): ConnectionInterface
151    {
152        return Database::connection(static::$connection);
153    }
154
155    /**
156     * The table name for this model.
157     *
158     * @return string
159     */
160    final public static function table(): string
161    {
162        $tableName = MetadataFactory::for(static::class)->tableName;
163
164        if ($tableName === null) {
165            throw new \LogicException(
166                'Model [' . static::class . '] declares no columns of its own and resolves no '
167                . 'table. Add #[Column] properties, or extend a table-owning model '
168                . 'behavior-only (no new columns, no #[Table]).'
169            );
170        }
171
172        return $tableName;
173    }
174
175    // ---- Query entry points ----
176
177    /**
178     * A fresh model query builder for this class.
179     *
180     * @return ModelQueryBuilder<static>
181     */
182    final public static function newQuery(): ModelQueryBuilder
183    {
184        return new ModelQueryBuilder(static::class, static::connection());
185    }
186
187    /**
188     * Find a model by its primary key.
189     *
190     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
191     * @return static|null
192     */
193    final public static function find(int|string|null|array $id): ?static
194    {
195        return static::newQuery()->find($id);
196    }
197
198    /**
199     * Find a model by its primary key or throw if it does not exist.
200     *
201     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
202     * @return static
203     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
204     */
205    final public static function findOrFail(int|string|null|array $id): static
206    {
207        return static::newQuery()->findOrFail($id);
208    }
209
210    /**
211     * Get the first model of the table or throw if the table is empty.
212     *
213     * @return static
214     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
215     */
216    final public static function firstOrFail(): static
217    {
218        return static::newQuery()->firstOrFail();
219    }
220
221    /**
222     * Get the single model of the table or throw if the count differs.
223     *
224     * @return static
225     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
226     * @throws \BlueprintAU\Radiant\Exceptions\MultipleRecordsFoundException
227     */
228    final public static function sole(): static
229    {
230        return static::newQuery()->sole();
231    }
232
233    /**
234     * Find a model by its primary key, or create one carrying that key.
235     *
236     * The PK map is both the match and the fill, so a miss always
237     * creates an addressable row. A hit reads
238     * {@see static::$wasRecentlyCreated} as false, a miss as true.
239     *
240     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
241     * @param  array<string, mixed>  $values  Extra column values for the created model.
242     * @return static
243     * @throws \InvalidArgumentException
244     * @throws WriteVetoException
245     */
246    final public static function findOrCreate(int|string|null|array $id, array $values = []): static
247    {
248        return static::newQuery()->findOrCreate($id, $values);
249    }
250
251    /**
252     * Get every model in the table.
253     *
254     * @return Collection<int, static>
255     */
256    final public static function all(): Collection
257    {
258        return static::newQuery()->get();
259    }
260
261    // ---- Static reads ----
262
263    /**
264     * Get the first model of the table, or null when it is empty.
265     *
266     * @return static|null
267     */
268    final public static function first(): ?static
269    {
270        return static::newQuery()->first();
271    }
272
273    /**
274     * Count the models in the table.
275     *
276     * @return int
277     */
278    final public static function count(): int
279    {
280        return static::newQuery()->count();
281    }
282
283    /**
284     * Whether any model exists in the table.
285     *
286     * @return bool
287     */
288    final public static function exists(): bool
289    {
290        return static::newQuery()->exists();
291    }
292
293    /**
294     * The value of a single column from the first row, decoded through
295     * the column's cast.
296     *
297     * @param  string|Aggregate  $column
298     * @return mixed
299     */
300    final public static function value(string|Aggregate $column): mixed
301    {
302        return static::newQuery()->value($column);
303    }
304
305    /**
306     * A collection of a single column's values, decoded through the casts.
307     *
308     * @param  string  $column
309     * @return BaseCollection<int, mixed>
310     */
311    final public static function pluck(string $column): BaseCollection
312    {
313        return static::newQuery()->pluck($column);
314    }
315
316    /**
317     * The maximum value of a column, decoded through the cast for
318     * declared columns.
319     *
320     * @param  string  $column
321     * @return mixed
322     */
323    final public static function max(string $column): mixed
324    {
325        return static::newQuery()->max($column);
326    }
327
328    /**
329     * The minimum value of a column, decoded through the cast for
330     * declared columns.
331     *
332     * @param  string  $column
333     * @return mixed
334     */
335    final public static function min(string $column): mixed
336    {
337        return static::newQuery()->min($column);
338    }
339
340    /**
341     * The sum of a column's values, decoded through the cast for
342     * declared columns.
343     *
344     * @param  string  $column
345     * @return mixed
346     */
347    final public static function sum(string $column): mixed
348    {
349        return static::newQuery()->sum($column);
350    }
351
352    /**
353     * The average of a column's values, decoded through the cast for
354     * declared columns.
355     *
356     * @param  string  $column
357     * @return mixed
358     */
359    final public static function avg(string $column): mixed
360    {
361        return static::newQuery()->avg($column);
362    }
363
364    /**
365     * Multiple aggregates in one query, decoded through each aggregate's
366     * column cast.
367     *
368     * @param  Aggregate  ...$aggregates
369     * @return \stdClass
370     */
371    final public static function aggregates(Aggregate ...$aggregates): \stdClass
372    {
373        return static::newQuery()->aggregates(...$aggregates);
374    }
375
376    /**
377     * Run one aggregate per group of the matching rows — a grouped
378     * aggregate in a single query.
379     *
380     * @param  Aggregate  $aggregate
381     * @param  string  $groupBy
382     * @return BaseCollection<string, mixed>
383     */
384    final public static function aggregateBy(Aggregate $aggregate, string $groupBy): BaseCollection
385    {
386        return static::newQuery()->aggregateBy($aggregate, $groupBy);
387    }
388
389    /**
390     * Count the matching rows per group of a column — in a single query.
391     *
392     * @param  string  $column
393     * @param  list<int|string>|null  $seed  Group values guaranteed to appear (0 when absent).
394     * @return BaseCollection<string, int>
395     */
396    final public static function countBy(string $column, ?array $seed = null): BaseCollection
397    {
398        return static::newQuery()->countBy($column, $seed);
399    }
400
401    /**
402     * Stream every model in the table, hydrating each row as it arrives.
403     *
404     * @return \Generator<int, static>
405     */
406    final public static function cursor(): \Generator
407    {
408        return static::newQuery()->cursor();
409    }
410
411    // ---- Static filter-modifier forwarders ----
412
413    /**
414     * Start a model query with a where clause.
415     *
416     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
417     * @param  WhereOperator|string  $operator
418     * @param  mixed  $value
419     * @param  WhereBoolean  $boolean
420     * @return ModelQueryBuilder<static>
421     */
422    final public static function where(
423        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
424        WhereOperator|string $operator,
425        mixed $value,
426        WhereBoolean $boolean = WhereBoolean::And,
427    ): ModelQueryBuilder {
428        return static::newQuery()->where($column, $operator, $value, $boolean);
429    }
430
431    /**
432     * Start a model query with a nested where group.
433     *
434     * @param  callable(\BlueprintAU\Radiant\Database\Query\WhereBuilder): \BlueprintAU\Radiant\Database\Query\WhereBuilder  $callback
435     * @param  WhereBoolean  $boolean
436     * @return ModelQueryBuilder<static>
437     */
438    final public static function whereNested(
439        callable $callback,
440        WhereBoolean $boolean = WhereBoolean::And,
441    ): ModelQueryBuilder {
442        return static::newQuery()->whereNested($callback, $boolean);
443    }
444
445    /**
446     * Start a model query with an `EXISTS (subquery)` clause.
447     *
448     * @param  QueryBuilder  $query  The existential subquery — another model's `newQuery()`, correlated via `whereColumn()`.
449     * @param  WhereBoolean  $boolean
450     * @param  bool  $negated  True renders `NOT EXISTS`.
451     * @return ModelQueryBuilder<static>
452     */
453    final public static function whereExists(
454        QueryBuilder $query,
455        WhereBoolean $boolean = WhereBoolean::And,
456        bool $negated = false,
457    ): ModelQueryBuilder {
458        return static::newQuery()->whereExists($query, $boolean, $negated);
459    }
460
461    /**
462     * Start a model query with a `column IN (subquery)` clause.
463     *
464     * @param  string  $column  The outer column the IN constrains.
465     * @param  QueryBuilder  $query  The single-column value subquery.
466     * @param  WhereBoolean  $boolean
467     * @param  bool  $negated  True renders `NOT IN`.
468     * @return ModelQueryBuilder<static>
469     */
470    final public static function whereInQuery(
471        string $column,
472        QueryBuilder $query,
473        WhereBoolean $boolean = WhereBoolean::And,
474        bool $negated = false,
475    ): ModelQueryBuilder {
476        return static::newQuery()->whereInQuery($column, $query, $boolean, $negated);
477    }
478
479    /**
480     * Start a model query with an order-by clause.
481     *
482     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
483     * @param  SortDirection|string  $direction
484     * @return ModelQueryBuilder<static>
485     */
486    final public static function orderBy(
487        string|\BlueprintAU\Radiant\Database\Query\Expression $column,
488        SortDirection|string $direction = SortDirection::Asc,
489    ): ModelQueryBuilder {
490        return static::newQuery()->orderBy($column, $direction);
491    }
492
493    /**
494     * Start a model query with a row limit.
495     *
496     * @param  int  $limit
497     * @return ModelQueryBuilder<static>
498     */
499    final public static function limit(int $limit): ModelQueryBuilder
500    {
501        return static::newQuery()->limit($limit);
502    }
503
504    /**
505     * Start a model query with a row offset.
506     *
507     * @param  int  $offset
508     * @return ModelQueryBuilder<static>
509     */
510    final public static function offset(int $offset): ModelQueryBuilder
511    {
512        return static::newQuery()->offset($offset);
513    }
514
515    /**
516     * Start a model query with an explicit column selection.
517     *
518     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate  ...$columns
519     * @return ModelQueryBuilder<static>
520     */
521    final public static function select(string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate ...$columns): ModelQueryBuilder
522    {
523        return static::newQuery()->select(...$columns);
524    }
525
526    /**
527     * Start a model query grouped by one or more columns.
528     *
529     * @param  string|array<int, string>  $columns
530     * @return ModelQueryBuilder<static>
531     */
532    final public static function groupBy(string|array $columns): ModelQueryBuilder
533    {
534        return static::newQuery()->groupBy($columns);
535    }
536
537    /**
538     * Start a model query with a having clause.
539     *
540     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate  $column
541     * @param  WhereOperator|string  $operator
542     * @param  mixed  $value
543     * @return ModelQueryBuilder<static>
544     */
545    final public static function having(
546        string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate $column,
547        WhereOperator|string $operator,
548        mixed $value,
549    ): ModelQueryBuilder {
550        return static::newQuery()->having($column, $operator, $value);
551    }
552
553    /**
554     * Start a model query with eager-loaded relations.
555     *
556     * Dot-notation nests: `'posts.comments'` eager-loads posts, then each
557     * post's comments. Spread a computed list through the variadic:
558     * `User::with(...$paths)`.
559     *
560     * @param  string  ...$relations
561     * @return ModelQueryBuilder<static>
562     * @throws \InvalidArgumentException
563     */
564    final public static function with(string ...$relations): ModelQueryBuilder
565    {
566        return static::newQuery()->with(...$relations);
567    }
568
569    // ---- Persistence ----
570
571    /**
572     * A fresh timestamp for the stamp and delete columns.
573     *
574     * @return \Carbon\Carbon
575     */
576    protected static function freshTimestamp(): \Carbon\Carbon
577    {
578        return \Carbon\Carbon::now();
579    }
580
581    // ---- Lifecycle callbacks ----
582
583    /**
584     * Register a callback to run after a successful INSERT or UPDATE.
585     *
586     * @param  callable(static $model): void  $callback
587     * @return void
588     */
589    final public function saved(callable $callback): void
590    {
591        $this->lifecycleCallbacks['saved'][] = $callback;
592    }
593
594    /**
595     * Register a callback to run after a successful delete — soft or hard.
596     *
597     * @param  callable(static $model): void  $callback
598     * @return void
599     */
600    final public function deleted(callable $callback): void
601    {
602        $this->lifecycleCallbacks['deleted'][] = $callback;
603    }
604
605    /**
606     * Register a callback to run before the INSERT/UPDATE payload is
607     * built — listeners may mutate the model (the mutation lands in the
608     * write) or return false to cancel the save.
609     *
610     * @param  callable(static $model): (bool|void)  $callback
611     * @return void
612     */
613    final public function saving(callable $callback): void
614    {
615        $this->lifecycleCallbacks['saving'][] = $callback;
616    }
617
618    /**
619     * Register a callback to run before a delete is attempted — soft or
620     * hard. Returning false vetoes the delete.
621     *
622     * @param  callable(static $model): (bool|void)  $callback
623     * @return void
624     */
625    final public function deleting(callable $callback): void
626    {
627        $this->lifecycleCallbacks['deleting'][] = $callback;
628    }
629
630    /**
631     * Register a callback to run before restore() clears the soft-delete
632     * stamp. Returning false vetoes the restore.
633     *
634     * @param  callable(static $model): (bool|void)  $callback
635     * @return void
636     */
637    final public function restoring(callable $callback): void
638    {
639        $this->lifecycleCallbacks['restoring'][] = $callback;
640    }
641
642    /**
643     * Register a callback to run after restore() clears the soft-delete
644     * stamp.
645     *
646     * @param  callable(static $model): void  $callback
647     * @return void
648     */
649    final public function restored(callable $callback): void
650    {
651        $this->lifecycleCallbacks['restored'][] = $callback;
652    }
653
654    /**
655     * Fire one lifecycle event's callbacks in registration order.
656     *
657     * Attempt events (`saving`, `deleting`, `restoring`) stop at the first
658     * listener returning `false` — the action is vetoed. Success events
659     * (`saved`, `deleted`, `restored`) run every listener.
660     *
661     * @param  string  $event
662     * @return bool False when an attempt listener vetoed the action.
663     */
664    final protected function fireLifecycle(string $event): bool
665    {
666        foreach ($this->lifecycleCallbacks[$event] ?? [] as $callback) {
667            $result = $callback($this);
668
669            if ($result === false) {
670                return false;
671            }
672        }
673
674        return true;
675    }
676
677    /**
678     * Save the model — INSERT when new, UPDATE of the dirty columns when not.
679     *
680     * A `saving` listener returning false vetoes the save via a thrown
681     * {@see WriteVetoException}.
682     *
683     * @return void
684     * @throws \LogicException
685     * @throws WriteVetoException
686     */
687    final public function save(): void
688    {
689        if (!$this->fireLifecycle('saving')) {
690            throw WriteVetoException::listener(static::class, 'saving');
691        }
692
693        if (!$this->exists) {
694            $this->performInsert();
695
696            return;
697        }
698
699        $softDeleteColumn = MetadataFactory::for(static::class)->softDeleteColumn;
700
701        if ($softDeleteColumn !== null && ($this->original[$softDeleteColumn] ?? null) !== null) {
702            throw new \LogicException(
703                'The model [' . static::class . '] is soft-deleted — its UPDATE would carry the '
704                . 'auto-applied `deleted_at IS NULL` scope, match 0 rows, and report success for '
705                . 'a write that never landed. Call restore() first (or forceDelete() to remove '
706                . 'the row permanently).'
707            );
708        }
709
710        // MTI children update per-partition (single query when the dirty
711        // columns land on one table, a transaction across tables otherwise).
712        if (MetadataFactory::for(static::class)->isMtiChild()) {
713            $this->performMtiUpdate();
714
715            $this->fireLifecycle('saved');
716
717            return;
718        }
719
720        $this->performUpdate();
721    }
722
723    /**
724     * Delete the model — a trait's `#[WriteHook(Hook::Delete)]` method may
725     * claim the delete (e.g. soft delete); with no claimant the row is
726     * hard-deleted.
727     *
728     * A `deleting` listener returning false vetoes via a thrown
729     * {@see WriteVetoException}; 0 affected rows throws a
730     * {@see StaleRowException}.
731     *
732     * @return void
733     * @throws \LogicException
734     * @throws WriteVetoException
735     * @throws StaleRowException
736     */
737    final public function delete(): void
738    {
739        if (!$this->fireLifecycle('deleting')) {
740            throw WriteVetoException::listener(static::class, 'deleting');
741        }
742
743        if ($this->dispatchWriteHooks(Hook::Delete)) {
744            // The claiming trait owns the instance bookkeeping for its
745            // path — a soft delete leaves the row in the table (exists
746            // stays true; trashed() reflects state), while a stale
747            // instance clears it. A failed claim threw in the dispatcher.
748            $this->fireLifecycle('deleted');
749
750            return;
751        }
752
753        $this->performDelete();
754
755        $this->fireLifecycle('deleted');
756    }
757
758    /**
759     * Dispatch one hook path's trait methods in collection order.
760     *
761     * A `true` return means a trait claimed the write; a failed claim
762     * throws {@see WriteVetoException}.
763     *
764     * @param  Hook  $hook
765     * @return bool
766     * @throws WriteVetoException
767     */
768    final protected function dispatchWriteHooks(Hook $hook): bool
769    {
770        foreach (MetadataFactory::for(static::class)->writeHooks as $entry) {
771            if ($entry['hook'] !== $hook) {
772                continue;
773            }
774
775            $result = $this->{$entry['method']}();
776
777            if ($result !== null) {
778                if ($result === false) {
779                    throw WriteVetoException::hook(
780                        static::class,
781                        $hook->value,
782                        $entry['trait'],
783                        $entry['method'],
784                    );
785                }
786
787                return true;
788            }
789        }
790
791        return false;
792    }
793
794    /**
795     * Dispatch the insert hooks on a bulk row set.
796     *
797     * Rows arrive in the PHP value space, before column encoding. All
798     * hooks for the path run — there are no claims. A `void` return is an
799     * observer; a `bool` return of `false` vetoes the write.
800     *
801     * @param  class-string<Model>  $modelClass
802     * @param  list<array<int|string, mixed>>  $rows
803     */
804    final public static function dispatchInsertHooks(string $modelClass, array &$rows): void
805    {
806        foreach (MetadataFactory::for($modelClass)->rowHooks as $entry) {
807            if ($entry['hook'] !== Hook::Insert) {
808                continue;
809            }
810
811            // Dynamic calls cannot pass the array by reference, so each
812            // hook is invoked through a closure scoped to the model class —
813            // protected static hook methods stay reachable from the
814            // builder. Bound only when a hook actually runs: the common
815            // hook-less write pays nothing.
816            $call = \Closure::bind(
817                static function (string $method, array &$rows) {
818                    return static::{$method}($rows);
819                },
820                null,
821                $modelClass,
822            );
823
824            if ($call($entry['method'], $rows) === false) {
825                throw WriteVetoException::rowHook(
826                    $modelClass,
827                    $entry['hook']->value,
828                    $entry['trait'],
829                    $entry['method'],
830                );
831            }
832        }
833    }
834
835    /**
836     * Dispatch the update hooks on an update values map.
837     *
838     * Values arrive in the PHP value space, before column encoding. All
839     * hooks for the path run — there are no claims. A `void` return is an
840     * observer; a `bool` return of `false` vetoes the write.
841     *
842     * @param  class-string<Model>  $modelClass
843     * @param  array<string|int, mixed>  $values
844     */
845    final public static function dispatchUpdateHooks(string $modelClass, array &$values): void
846    {
847        foreach (MetadataFactory::for($modelClass)->rowHooks as $entry) {
848            if ($entry['hook'] !== Hook::Update) {
849                continue;
850            }
851
852            // Same scoped invocation as {@see dispatchInsertHooks()} — a
853            // by-ref array cannot pass through a shared dispatcher without
854            // widening the caller's type to a rows-or-values union. Bound
855            // only when a hook actually runs.
856            $call = \Closure::bind(
857                static function (string $method, array &$values) {
858                    return static::{$method}($values);
859                },
860                null,
861                $modelClass,
862            );
863
864            if ($call($entry['method'], $values) === false) {
865                throw WriteVetoException::rowHook(
866                    $modelClass,
867                    $entry['hook']->value,
868                    $entry['trait'],
869                    $entry['method'],
870                );
871            }
872        }
873    }
874
875    /**
876     * The real DELETE by primary key.
877     *
878     * A delete that targets its row must delete: 0 affected rows means
879     * the row was already removed by someone else, and a
880     * {@see StaleRowException} throws rather than silently no-oping.
881     *
882     * @return void
883     * @throws \LogicException
884     * @throws StaleRowException
885     */
886    final protected function performDelete(): void
887    {
888        // A null key would compile to `WHERE pk IS NULL` — matching nothing,
889        // or the wrong rows on dialects that permit NULL keys.
890        $this->assertKeyResolvedForWrite();
891
892        // Destroy observers run on EVERY hard delete — delete() with no
893        // claimant AND forceDelete() — so audit traits always see the row
894        // going away. The hard DELETE itself is unclaimable.
895        $this->dispatchWriteHooks(Hook::Destroy);
896
897        $metadata = MetadataFactory::for(static::class);
898
899        // MTI: leaf-first deletes up the chain — each level removes its own
900        // row. (The schema-level ON DELETE CASCADE is the backstop; the
901        // explicit deletes are the runtime path, portable across dialects
902        // that enforce FKs differently.)
903        if ($metadata->isMtiChild()) {
904            $key = $this->getKeyForRefresh();
905            $pks = static::getPrimaryKeys();
906
907            // Composite MTI keys delete by the full key tuple — every level
908            // shares ALL the key columns, so each table's DELETE matches on
909            // the same column => value pairs. A single key keeps the
910            // scalar form (one where, one binding).
911            $composite = count($pks) > 1;
912
913            $anyDeleted = false;
914
915            for ($class = static::class; $class !== false; $class = get_parent_class($class)) {
916                if (!is_a($class, Model::class, true)) {
917                    continue;
918                }
919
920                $levelMetadata = MetadataFactory::for($class);
921                $levelTable = $levelMetadata->tableName;
922
923                if ($levelTable === null) {
924                    continue;
925                }
926
927                $query = static::connection()->table($levelTable);
928
929                if ($composite) {
930                    foreach ($pks as $pk) {
931                        if ($pk->name !== null) {
932                            $query = $query->where($pk->name, WhereOperator::Eq, $key[$pk->name] ?? null);
933                        }
934                    }
935                } else {
936                    $query = $query->where($pks[0]->name ?? 'id', WhereOperator::Eq, $key);
937                }
938
939                $deleted = $query->delete();
940                $anyDeleted = $anyDeleted || $deleted > 0;
941            }
942
943            // The leaf delete is the caller's contract — a cascade may
944            // remove ancestor partitions first (0 affected there is
945            // expected), but if NOTHING went away the row was already
946            // gone.
947            if (!$anyDeleted) {
948                throw new StaleRowException(static::class, 'delete');
949            }
950
951            $this->exists = false;
952            $this->wasRecentlyCreated = false;
953
954            return;
955        }
956
957        $deleted = $this->newQuery()->withTrashed()->whereKey($this->getKeyForRefresh())->delete();
958
959        if ($deleted === 0) {
960            throw new StaleRowException(static::class, 'delete');
961        }
962
963        $this->exists = false;
964        $this->wasRecentlyCreated = false;
965    }
966
967    /**
968     * INSERT the model.
969     *
970     * @return void
971     * @throws WriteVetoException
972     */
973    final protected function performInsert(): void
974    {
975        $metadata = MetadataFactory::for(static::class);
976
977        // MTI: split the insert per table — root first (generating the id),
978        // then each descendant, in ONE transaction on a SQL connection.
979        if ($metadata->isMtiChild()) {
980            $this->performMtiInsert($metadata);
981
982            $this->fireLifecycle('saved');
983
984            return;
985        }
986
987        // Insert-path trait hooks — observers stamp values BEFORE
988        // getColumnValues() builds the payload, so the insert carries them
989        // and syncOriginal() snapshots them. A claimant performs the
990        // insert itself; a failed claim threw in the dispatcher.
991        if ($this->dispatchWriteHooks(Hook::Insert)) {
992            return;
993        }
994
995        $values = $this->getColumnValues();
996        $pks = static::getPrimaryKeys();
997
998        if (count($pks) === 1 && $pks[0]->autoIncrement) {
999            $this->setPrimaryKey($this->newQuery()->insertGetId($values));
1000        } else {
1001            $this->newQuery()->insert($values);
1002        }
1003
1004        $this->exists = true;
1005        $this->wasRecentlyCreated = true;
1006        $this->materializeDefaults();
1007        $this->syncOriginal();
1008        $this->fireLifecycle('saved');
1009    }
1010
1011    /**
1012     * Materialize declared column defaults onto uninitialized properties
1013     * after a successful INSERT.
1014     *
1015     * @return void
1016     */
1017    private function materializeDefaults(): void
1018    {
1019        foreach (static::getProperties() as $mapping) {
1020            $property = $mapping->property;
1021
1022            if ($property === null || $property->isInitialized($this)) {
1023                continue;
1024            }
1025
1026            $default = $mapping->column->default;
1027
1028            if ($default === null || $default instanceof \BlueprintAU\Radiant\Database\Query\Expression) {
1029                continue;
1030            }
1031
1032            $property->setValue($this, $mapping->column->decode($default, $mapping->propertyType));
1033        }
1034    }
1035
1036    /**
1037     * INSERT an MTI chain: root partition first, then each level, all in
1038     * one transaction.
1039     *
1040     * @param  \BlueprintAU\Radiant\Metadata\ClassMetadata  $metadata
1041     * @return void
1042     * @throws \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException
1043     * @throws WriteVetoException
1044     */
1045    final protected function performMtiInsert(\BlueprintAU\Radiant\Metadata\ClassMetadata $metadata): void
1046    {
1047        // Insert-path trait hooks — MTI children get the same stamping as
1048        // single-table models (the hook fires before the payload build).
1049        // A failed claim threw in the dispatcher.
1050        if ($this->dispatchWriteHooks(Hook::Insert)) {
1051            return;
1052        }
1053
1054        // Fail fast with the MTI-specific message: the insert splits across
1055        // tables in one transaction, which only a SQL connection can do.
1056        $connection = static::connection();
1057
1058        if (!$connection instanceof \BlueprintAU\Radiant\Database\Connections\SqlConnection) {
1059            throw new \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException(
1060                'Multi-table inheritance writes require a SQL connection (the insert splits across tables in one transaction).'
1061            );
1062        }
1063
1064        $pk = static::getPrimaryKeys()[0];
1065        $pkName = $pk->name ?? throw new \LogicException(
1066            'MTI requires a single named primary key on the root table.'
1067        );
1068
1069        // Late static binding does NOT flow into a closure's
1070        // get_parent_class()/static:: calls — the closure's scope is the
1071        // defining class (Model). Capture the concrete class here and walk
1072        // the chain from it.
1073        $leafClass = static::class;
1074
1075        // A caller-assigned (non-auto-increment) root key MUST be present:
1076        // without it the root INSERT omits the PK and the descendant
1077        // partitions have nothing to link against. (An auto-increment root
1078        // generates its own — an unset key is expected there.)
1079        if (!self::rootAutoIncrement($leafClass)) {
1080            $this->assertAssignedMtiKeyPresent($pkName);
1081        }
1082
1083        $connection->transaction(function () use ($connection, $pkName, $leafClass): void {
1084            // Walk root-first: each level's own columns go to its own table.
1085            $chain = [];
1086
1087            for ($class = $leafClass; $class !== false; $class = get_parent_class($class)) {
1088                if (!is_a($class, Model::class, true)) {
1089                    continue;
1090                }
1091
1092                $levelMetadata = MetadataFactory::for($class);
1093
1094                if ($levelMetadata->tableName === null) {
1095                    continue;
1096                }
1097
1098                $chain[] = [$levelMetadata, $levelMetadata->tableName];
1099            }
1100
1101            // chain is child-first; reverse to insert the root first.
1102            $chain = array_reverse($chain);
1103
1104            $generatedId = null;
1105
1106            foreach ($chain as [$levelMetadata, $levelTable]) {
1107                $values = [];
1108
1109                foreach ($levelMetadata->properties as $mapping) {
1110                    // Partition by the mapping's OWNER's table — NOT the
1111                    // leaf's partition map. The leaf's map routes the shared
1112                    // PK to the CHILD table (the derived mapping's owner is
1113                    // the child class), so a leaf-side lookup would skip the
1114                    // root's own PK mapping here and the root INSERT would
1115                    // omit the key entirely.
1116                    $ownerTable = MetadataFactory::for($mapping->owner)->tableName;
1117
1118                    if ($ownerTable === null || $ownerTable !== $levelTable) {
1119                        continue; // another level's column
1120                    }
1121
1122                    // The shared PK: root generates it, descendants copy it.
1123                    if ($mapping->columnName === $pkName) {
1124                        $value = $generatedId ?? ($this->original[$pkName] ?? $this->encodedPkValue($mapping));
1125
1126                        if ($value !== null) {
1127                            $values[$pkName] = $value;
1128                        }
1129
1130                        continue;
1131                    }
1132
1133                    if ($mapping->property === null) {
1134                        if (array_key_exists($mapping->columnName, $this->syntheticValues)) {
1135                            $values[$mapping->columnName] = $mapping->column->encode(
1136                                $this->syntheticValues[$mapping->columnName],
1137                                $mapping->propertyType,
1138                            );
1139                        }
1140                        continue;
1141                    }
1142
1143                    if ($mapping->property->isInitialized($this) === false) {
1144                        continue;
1145                    }
1146
1147                    $values[$mapping->columnName] = $mapping->column->encode(
1148                        $mapping->property->getValue($this),
1149                        $mapping->propertyType,
1150                    );
1151                }
1152
1153                // ONLY the root table generates the id — the decision uses
1154                // the ROOT's key (autoIncrement true there), not the child's
1155                // derived clone (autoIncrement false by design).
1156                $levelRoot = $generatedId === null;
1157
1158                if ($levelRoot) {
1159                    // Capture the caller-assigned key BEFORE the unset — the
1160                    // unset exists so an auto-increment root doesn't INSERT
1161                    // a null/placeholder key, but a caller-assigned root's
1162                    // key IS the generated id and must survive it.
1163                    $assignedKey = $values[$pkName] ?? null;
1164
1165                    unset($values[$pkName]);
1166
1167                    $builder = $connection->table($levelTable);
1168
1169                    if (self::rootAutoIncrement($leafClass)) {
1170                        $generatedId = $builder->insertIdColumn($pkName, true)->insertGetId($values);
1171                    } elseif ($assignedKey !== null) {
1172                        $values[$pkName] = $assignedKey; // caller-assigned key
1173                        $generatedId = $assignedKey;
1174                        $builder->insert($values);
1175                    } else {
1176                        $builder->insert($values);
1177                    }
1178                } else {
1179                    // Descendant: the shared id MUST ride along — the FK is
1180                    // the link. The PK mapping's value was already copied
1181                    // above when present; fill it from the generated id.
1182                    if (!isset($values[$pkName])) {
1183                        $values[$pkName] = $generatedId;
1184                    }
1185
1186                    $connection->table($levelTable)->insert($values);
1187                }
1188            }
1189
1190            $this->setPrimaryKey($generatedId);
1191        });
1192
1193        $this->exists = true;
1194        $this->wasRecentlyCreated = true;
1195        $this->materializeDefaults();
1196        $this->syncOriginal();
1197    }
1198
1199    /**
1200     * Whether the MTI chain's root table has an auto-increment key.
1201     *
1202     * @param  class-string<Model>  $leafClass
1203     * @return bool
1204     */
1205    private static function rootAutoIncrement(string $leafClass): bool
1206    {
1207        $class = $leafClass;
1208
1209        while (true) {
1210            $metadata = MetadataFactory::for($class);
1211
1212            if ($metadata->parentModel === null) {
1213                return $metadata->primaryKeys[0]->autoIncrement;
1214            }
1215
1216            $class = $metadata->parentModel;
1217        }
1218    }
1219
1220    /**
1221     * The encoded PK value from a typed property for a new model.
1222     *
1223     * @param  \BlueprintAU\Radiant\Metadata\PropertyMapping  $mapping
1224     * @return string|int|null
1225     */
1226    private function encodedPkValue(\BlueprintAU\Radiant\Metadata\PropertyMapping $mapping): string|int|null
1227    {
1228        if ($mapping->property === null || $mapping->property->isInitialized($this) === false) {
1229            return null;
1230        }
1231
1232        $value = $mapping->column->encode($mapping->property->getValue($this), $mapping->propertyType);
1233
1234        return is_string($value) || is_int($value) ? $value : null;
1235    }
1236
1237    /**
1238     * Write the generated id back onto the single auto-increment PK property.
1239     *
1240     * @param  string|int|null  $id
1241     * @return void
1242     */
1243    final protected function setPrimaryKey(string|int|null $id): void
1244    {
1245        if ($id === null) {
1246            return;
1247        }
1248
1249        $pk = static::getPrimaryKeys()[0];
1250
1251        foreach (static::getProperties() as $mapping) {
1252            if ($mapping->columnName === $pk->name && $mapping->property !== null) {
1253                $mapping->property->setValue($this, $id);
1254                return;
1255            }
1256        }
1257    }
1258
1259    /**
1260     * UPDATE the dirty columns by primary key.
1261     *
1262     * @return void
1263     * @throws WriteVetoException
1264     */
1265    final protected function performUpdate(): void
1266    {
1267        // Update-path trait hooks — stamping BEFORE getDirty() means the
1268        // bumped values show up in the dirty set (and a no-op update with
1269        // no other changes still writes the stamp). A claimant performs
1270        // the update itself; a failed claim threw in the dispatcher.
1271        if ($this->dispatchWriteHooks(Hook::Update)) {
1272            return;
1273        }
1274
1275        $dirty = $this->getDirty();
1276
1277        if ($dirty !== []) {
1278            // A null key would compile to `WHERE pk IS NULL` — the UPDATE
1279            // would match nothing yet report success.
1280            $this->assertKeyResolvedForWrite();
1281
1282            $this->newQuery()->whereKey($this->getKeyForRefresh())->update($dirty);
1283        }
1284
1285        $this->syncOriginal();
1286        $this->fireLifecycle('saved');
1287    }
1288
1289    /**
1290     * UPDATE the dirty columns, split per owning table when the model is
1291     * an MTI child.
1292     *
1293     * @return void
1294     * @throws WriteVetoException
1295     */
1296    final protected function performMtiUpdate(): void
1297    {
1298        // Update-path trait hooks — MTI children get the same stamping as
1299        // single-table models. A failed claim threw in the dispatcher.
1300        if ($this->dispatchWriteHooks(Hook::Update)) {
1301            return;
1302        }
1303
1304        $metadata = MetadataFactory::for(static::class);
1305        $dirty = $this->getDirty();
1306
1307        if ($dirty === []) {
1308            $this->syncOriginal();
1309
1310            return;
1311        }
1312
1313        // A null key would compile to `WHERE pk IS NULL` per partition —
1314        // the UPDATE would match nothing yet report success.
1315        $this->assertKeyResolvedForWrite();
1316
1317        $key = $this->getKeyForRefresh();
1318        $connection = static::connection();
1319        $pks = static::getPrimaryKeys();
1320
1321        // Partition the dirty columns per owning table.
1322        $perTable = [];
1323
1324        foreach ($dirty as $column => $value) {
1325            $perTable[$metadata->tableFor($column)][$column] = $value;
1326        }
1327
1328        $multiTable = count($perTable) > 1;
1329
1330        // A composite MTI key retargets every partition with the full key
1331        // tuple (all levels share ALL key columns); a single key keeps the
1332        // scalar form.
1333        $composite = count($pks) > 1;
1334
1335        $apply = function () use ($perTable, $key, $connection, $pks, $composite): void {
1336            foreach ($perTable as $table => $values) {
1337                $query = $connection->table($table);
1338
1339                if ($composite) {
1340                    foreach ($pks as $pk) {
1341                        if ($pk->name !== null) {
1342                            $query = $query->where($pk->name, WhereOperator::Eq, $key[$pk->name] ?? null);
1343                        }
1344                    }
1345                } else {
1346                    $query = $query->where($pks[0]->name ?? 'id', WhereOperator::Eq, $key);
1347                }
1348
1349                $query->update($values);
1350            }
1351        };
1352
1353        if ($multiTable) {
1354            // A multi-table update must be atomic — SQL-only, narrowed
1355            // fail-fast (UnsupportedFeatureException on a non-SQL backend),
1356            // no @var docblock needed.
1357            SqlConnection::from($connection)->transaction($apply);
1358        } else {
1359            $apply();
1360        }
1361
1362        $this->syncOriginal();
1363    }
1364
1365    // ---- Dirty tracking ----
1366
1367    /**
1368     * The columns changed since the last sync, keyed by column name with
1369     * their encoded (bindable) values.
1370     *
1371     * The comparison is strict against the encoded snapshot — PHP's loose
1372     * `!=` would silently drop legitimate writes like an int `0` onto a
1373     * column loaded as the string `'0'`.
1374     *
1375     * @return array<string, mixed>
1376     */
1377    final protected function getDirty(): array
1378    {
1379        $dirty = [];
1380
1381        foreach ($this->getColumnValues() as $column => $value) {
1382            if (!array_key_exists($column, $this->original) || $this->original[$column] !== $value) {
1383                $dirty[$column] = $value;
1384            }
1385        }
1386
1387        return $dirty;
1388    }
1389
1390    /**
1391     * Snapshot the current column values as the hydration-time original.
1392     *
1393     * @return void
1394     */
1395    final protected function syncOriginal(): void
1396    {
1397        $this->original = $this->getColumnValues();
1398    }
1399
1400    /**
1401     * The model's primary-key value for re-targeting the row.
1402     *
1403     * Lenient by design: read paths (Collection::find()/fresh()) treat an
1404     * unresolved key as "no match". Write paths guard separately via
1405     * {@see assertKeyResolvedForWrite()}.
1406     *
1407     * @return KeyValue
1408     */
1409    final public function getKeyForRefresh(): int|string|null|array
1410    {
1411        $pks = static::getPrimaryKeys();
1412
1413        if (count($pks) === 1 && $pks[0]->name !== null) {
1414            return $this->original[$pks[0]->name] ?? null;
1415        }
1416
1417        $key = [];
1418
1419        foreach ($pks as $pk) {
1420            if ($pk->name !== null) {
1421                $key[$pk->name] = $this->original[$pk->name] ?? null;
1422            }
1423        }
1424
1425        return $key;
1426    }
1427
1428    /**
1429     * Fail fast when a write cannot target its row — a primary-key
1430     * component with no value compiles to `WHERE pk IS NULL`, which
1431     * matches nothing (or, on dialects that permit NULL keys, the wrong
1432     * rows) while the caller is told the write succeeded.
1433     *
1434     * Read paths stay lenient — {@see getKeyForRefresh()} feeding
1435     * Collection::find()/fresh() treats an unresolved key as "no match" —
1436     * only the write paths (update, delete) pay for this guard.
1437     *
1438     * @return void
1439     * @throws \LogicException
1440     */
1441    private function assertKeyResolvedForWrite(): void
1442    {
1443        $key = $this->getKeyForRefresh();
1444
1445        $missing = [];
1446
1447        if (is_array($key)) {
1448            foreach ($key as $column => $value) {
1449                if ($value === null) {
1450                    $missing[] = $column;
1451                }
1452            }
1453        } elseif ($key === null) {
1454            $missing[] = static::getPrimaryKeys()[0]->name ?? '(unnamed)';
1455        }
1456
1457        if ($missing === []) {
1458            return;
1459        }
1460
1461        throw new \LogicException(
1462            'The write on model [' . static::class . '] cannot target its row — primary-key '
1463            . 'column(s) [' . implode(', ', $missing) . '] hold no value (the model was never '
1464            . 'saved, or its key was never hydrated). The statement would compile to '
1465            . '`WHERE pk IS NULL`, matching nothing — or, on dialects that permit NULL keys, '
1466            . 'the wrong rows. Save the model to generate its key, or assign a caller-managed '
1467            . 'key first.'
1468        );
1469    }
1470
1471    /**
1472     * Fail fast when an MTI insert's caller-assigned (non-auto-increment)
1473     * root key is missing — the root INSERT would omit the PK entirely and
1474     * the descendant partitions would have nothing to link against.
1475     *
1476     * @param  string  $pkName
1477     * @return void
1478     * @throws \LogicException
1479     */
1480    private function assertAssignedMtiKeyPresent(string $pkName): void
1481    {
1482        if (isset($this->original[$pkName])) {
1483            return;
1484        }
1485
1486        foreach (static::getProperties() as $mapping) {
1487            if ($mapping->columnName === $pkName && $this->encodedPkValue($mapping) !== null) {
1488                return;
1489            }
1490        }
1491
1492        throw new \LogicException(
1493            'The MTI insert on model [' . static::class . '] needs its caller-assigned '
1494            . 'primary key [' . $pkName . '] set — the root table does not auto-generate '
1495            . 'one, so the root INSERT would omit the key and the descendant partitions '
1496            . 'would have nothing to link against.'
1497        );
1498    }
1499
1500    // ---- Hydration (reconstitution, not creation) ----
1501
1502    /**
1503     * Build a blank instance of this model class.
1504     *
1505     * The single construction seam shared by reconstitution and the
1506     * create-family helpers: no constructor runs, no property is
1507     * initialized. Override to swap in custom construction — the method
1508     * never takes constructor args, so an override receives none either.
1509     *
1510     * @return static
1511     */
1512    public static function newInstance(): static
1513    {
1514        return (new \ReflectionClass(static::class))->newInstanceWithoutConstructor();
1515    }
1516
1517    /**
1518     * Reconstitute a model from a raw row.
1519     *
1520     * Hydration does not run the constructor; each column property is
1521     * decoded through its column's cast.
1522     *
1523     * @param  \stdClass  $row
1524     * @return static
1525     */
1526    final public static function fromRow(\stdClass $row): static
1527    {
1528        $instance = static::newInstance();
1529
1530        foreach (static::getProperties() as $mapping) {
1531            $columnName = $mapping->columnName;
1532
1533            if (!property_exists($row, $columnName)) {
1534                continue;
1535            }
1536
1537            if ($mapping->property === null) {
1538                // Synthetic column — no property slot to hydrate. Seed the
1539                // snapshot directly: the raw bytes ARE the encoded space
1540                // `$original` lives in, so no decode/encode round-trip is
1541                // needed (or wanted). For synthetic mappings
1542                // `propertyName === columnName` by construction (the
1543                // factory creates them from the same name), so this key
1544                // matches the rest of the column-keyed snapshot.
1545                $instance->original[$columnName] = $row->{$columnName};
1546                continue;
1547            }
1548
1549            $instance->hydrateProperty($mapping, $mapping->column->decode($row->{$columnName}, $mapping->propertyType));
1550        }
1551
1552        $instance->exists = true;
1553        $instance->wasRecentlyCreated = false;
1554        $instance->syncOriginal();
1555
1556        // Pivot columns from a BelongsToMany eager select ride the row as
1557        // `radiant_pivot_{column}` aliases — lift them onto the instance's
1558        // pivot store (a model hydrated WITHOUT a pivot join has none).
1559        foreach (get_object_vars($row) as $field => $value) {
1560            if (str_starts_with($field, 'radiant_pivot_')) {
1561                $instance->pivotValues[substr($field, strlen('radiant_pivot_'))] = $value;
1562            }
1563        }
1564
1565        return $instance;
1566    }
1567
1568    /**
1569     * A pivot value carried onto this model by a BelongsToMany eager load.
1570     *
1571     * @param  string  $column
1572     * @return mixed
1573     */
1574    final public function pivotValue(string $column): mixed
1575    {
1576        return $this->pivotValues[$column] ?? null;
1577    }
1578
1579    /**
1580     * Write one decoded value onto the instance.
1581     *
1582     * @param  PropertyMapping  $mapping
1583     * @param  mixed  $value
1584     * @return void
1585     */
1586    private function hydrateProperty(PropertyMapping $mapping, mixed $value): void
1587    {
1588        $property = $mapping->property;
1589
1590        if ($property === null) {
1591            return;
1592        }
1593
1594        $propertyType = $mapping->propertyType;
1595
1596        if (
1597            $value instanceof \DateTimeInterface
1598            && $propertyType !== null
1599            && $value::class !== $propertyType
1600            && is_a($propertyType, \DateTimeInterface::class, true)
1601            && !$value instanceof $propertyType
1602        ) {
1603            // DateTimeImmutable::createFromInterface etc. exist on every
1604            // concrete datetime class, but not on the interface itself —
1605            // reflect the method off the concrete class-string so PHPStan
1606            // sees a verified call, not a static guess.
1607            $method = new \ReflectionMethod($propertyType, 'createFromInterface');
1608            $value = $method->invoke(null, $value);
1609        }
1610
1611        $property->setValue($this, $value);
1612    }
1613
1614    /**
1615     * Read a column's current value by DB column name.
1616     *
1617     * Works for both typed-property columns and synthetic columns.
1618     *
1619     * @param  string  $columnName
1620     * @return mixed
1621     */
1622    final public function attribute(string $columnName): mixed
1623    {
1624        $mapping = MetadataFactory::for(static::class)->mappingFor($columnName);
1625
1626        if ($mapping->property === null) {
1627            // Runtime override wins; else decode the loaded snapshot.
1628            if (array_key_exists($columnName, $this->syntheticValues)) {
1629                return $this->syntheticValues[$columnName];
1630            }
1631
1632            $encoded = $this->original[$columnName] ?? null;
1633
1634            return $encoded === null ? null : $mapping->column->decode($encoded, $mapping->propertyType);
1635        }
1636
1637        if ($mapping->property->isInitialized($this) === false) {
1638            return null;
1639        }
1640
1641        return $mapping->property->getValue($this);
1642    }
1643
1644    /**
1645     * Write a synthetic column's runtime value.
1646     *
1647     * @param  string  $columnName
1648     * @param  mixed  $value
1649     * @return void
1650     * @throws \InvalidArgumentException
1651     */
1652    final public function setAttribute(string $columnName, mixed $value): void
1653    {
1654        $mapping = MetadataFactory::for(static::class)->mappingFor($columnName);
1655
1656        if ($mapping->property !== null) {
1657            throw new \InvalidArgumentException(
1658                'Column [' . $columnName . '] on model [' . static::class . '] is backed by a typed '
1659                . 'property; write the property directly instead of setAttribute().'
1660            );
1661        }
1662
1663        $this->syntheticValues[$columnName] = $value;
1664    }
1665
1666    /**
1667     * Write a column's value — the write twin of {@see attribute()}.
1668     *
1669     * A typed-property-backed column writes through the hydration
1670     * machinery. Synthetic columns throw — write them through
1671     * {@see setAttribute()} explicitly.
1672     *
1673     * @param  string  $columnName
1674     * @param  mixed  $value
1675     * @return void
1676     * @throws \InvalidArgumentException
1677     */
1678    final public function setColumn(string $columnName, mixed $value): void
1679    {
1680        $mapping = MetadataFactory::for(static::class)->mappingFor($columnName);
1681
1682        if ($mapping->property === null) {
1683            throw new \InvalidArgumentException(
1684                'Column [' . $columnName . '] on model [' . static::class . '] is a synthetic '
1685                . 'column (declared by a trait, no PHP property backs it); write it through '
1686                . 'setAttribute() explicitly if you need a runtime override.'
1687            );
1688        }
1689
1690        $this->hydrateProperty($mapping, $value);
1691    }
1692
1693    // ---- Metadata (delegating to the MetadataFactory cache) ----
1694
1695    /**
1696     * The class's merged column mappings, keyed by property name.
1697     *
1698     * @return PropertyMapping[]
1699     */
1700    protected static function getProperties(): array
1701    {
1702        return MetadataFactory::for(static::class)->properties;
1703    }
1704
1705    /**
1706     * The class's primary-key column declarations.
1707     *
1708     * @return list<Column>
1709     */
1710    protected static function getPrimaryKeys(): array
1711    {
1712        return MetadataFactory::for(static::class)->primaryKeys;
1713    }
1714
1715    /**
1716     * The current column values, keyed by column name with their encoded
1717     * (bindable) values.
1718     *
1719     * Unset (uninitialized) typed properties are skipped.
1720     *
1721     * @return array<string, mixed>
1722     */
1723    final protected function getColumnValues(): array
1724    {
1725        $values = [];
1726
1727        foreach (static::getProperties() as $mapping) {
1728            if ($mapping->property === null) {
1729                // Synthetic column — the runtime store holds the value when
1730                // a trait has written one post-load; otherwise the loaded
1731                // snapshot stands (seeded at hydration from the raw row).
1732                // Without the fallback, `syncOriginal()` would silently
1733                // drop the loaded value on every save.
1734                if (array_key_exists($mapping->columnName, $this->syntheticValues)) {
1735                    $values[$mapping->columnName] = $mapping->column->encode(
1736                        $this->syntheticValues[$mapping->columnName],
1737                        $mapping->propertyType,
1738                    );
1739                } elseif (array_key_exists($mapping->columnName, $this->original)) {
1740                    $values[$mapping->columnName] = $this->original[$mapping->columnName];
1741                }
1742                continue;
1743            }
1744
1745            if ($mapping->property->isInitialized($this) === false) {
1746                continue;
1747            }
1748
1749            $values[$mapping->columnName] = $mapping->column->encode(
1750                $mapping->property->getValue($this),
1751                $mapping->propertyType,
1752            );
1753        }
1754
1755        return $values;
1756    }
1757
1758    /**
1759     * Encode a single value for a column.
1760     *
1761     * @param  string  $columnName
1762     * @param  mixed  $value
1763     * @return mixed
1764     */
1765    final protected function castForWrite(string $columnName, mixed $value): mixed
1766    {
1767        $mapping = MetadataFactory::for(static::class)->mappingFor($columnName);
1768
1769        return $mapping->column->encode($value, $mapping->propertyType);
1770    }
1771
1772    // ---- Relations ----
1773
1774    /**
1775     * A one-to-many relation: this model's key is referenced by the
1776     * related table's FK.
1777     *
1778     * @template TRelated of Model
1779     *
1780     * @param  class-string<TRelated>  $related
1781     * @param  string|list<string>|null  $foreignKey
1782     * @param  string|list<string>|null  $localKey
1783     * @return Relations\HasMany<TRelated>
1784     * @throws \InvalidArgumentException
1785     */
1786    protected function hasMany(string $related, string|array|null $foreignKey = null, string|array|null $localKey = null): Relations\HasMany
1787    {
1788        $localKey ??= self::defaultLocalKey();
1789        $foreignKey ??= self::defaultForeignKeyFor($localKey);
1790
1791        self::assertColumnExists($related, $foreignKey, 'foreign key');
1792        self::assertColumnExists(static::class, $localKey, 'local key');
1793
1794        return (new Relations\HasMany($this, $related, $foreignKey, $localKey))
1795            ->withName(self::relationName());
1796    }
1797
1798    /**
1799     * A one-to-one relation: this model's key is referenced by the
1800     * related table's FK.
1801     *
1802     * @template TRelated of Model
1803     *
1804     * @param  class-string<TRelated>  $related
1805     * @param  string|list<string>|null  $foreignKey
1806     * @param  string|list<string>|null  $localKey
1807     * @return Relations\HasOne<TRelated>
1808     * @throws \InvalidArgumentException
1809     */
1810    protected function hasOne(string $related, string|array|null $foreignKey = null, string|array|null $localKey = null): Relations\HasOne
1811    {
1812        $localKey ??= self::defaultLocalKey();
1813        $foreignKey ??= self::defaultForeignKeyFor($localKey);
1814
1815        self::assertColumnExists($related, $foreignKey, 'foreign key');
1816        self::assertColumnExists(static::class, $localKey, 'local key');
1817
1818        return (new Relations\HasOne($this, $related, $foreignKey, $localKey))
1819            ->withName(self::relationName());
1820    }
1821
1822    /**
1823     * The inverse relation: this model's table holds the FK.
1824     *
1825     * @template TRelated of Model
1826     *
1827     * @param  class-string<TRelated>  $related
1828     * @param  string|list<string>|null  $foreignKey
1829     * @param  string|list<string>|null  $ownerKey
1830     * @return Relations\BelongsTo<TRelated>
1831     * @throws \InvalidArgumentException
1832     */
1833    protected function belongsTo(string $related, string|array|null $foreignKey = null, string|array|null $ownerKey = null): Relations\BelongsTo
1834    {
1835        $ownerKey ??= self::defaultLocalKeyOf($related);
1836        $foreignKey ??= self::defaultForeignKeyFromKey($ownerKey, $related);
1837
1838        self::assertColumnExists(static::class, $foreignKey, 'foreign key');
1839        self::assertColumnExists($related, $ownerKey, 'owner key');
1840
1841        return (new Relations\BelongsTo($this, $related, $foreignKey, $ownerKey))
1842            ->withName(self::relationName());
1843    }
1844
1845    /**
1846     * A one-to-one two-hop relation through an intermediate model.
1847     *
1848     * @template TRelated of Model
1849     *
1850     * @param  class-string<TRelated>  $related
1851     * @param  class-string<Model>  $through
1852     * @param  string|list<string>|null  $firstKey
1853     * @param  string|list<string>|null  $secondKey
1854     * @param  string|list<string>|null  $localKey
1855     * @return Relations\HasOneThrough<TRelated>
1856     * @throws \InvalidArgumentException
1857     */
1858    protected function hasOneThrough(
1859        string $related,
1860        string $through,
1861        string|array|null $firstKey = null,
1862        string|array|null $secondKey = null,
1863        string|array|null $localKey = null,
1864    ): Relations\HasOneThrough {
1865        $localKey ??= self::defaultLocalKey();
1866        $firstKey ??= self::defaultForeignKeyFor($localKey);
1867        $secondKey ??= self::defaultForeignKeyFrom($through);
1868
1869        self::assertColumnExists($through, $firstKey, 'first key');
1870        self::assertColumnExists($related, $secondKey, 'second key');
1871        self::assertColumnExists(static::class, $localKey, 'local key');
1872
1873        return (new Relations\HasOneThrough($this, $related, $through, $firstKey, $secondKey, $localKey))
1874            ->withName(self::relationName());
1875    }
1876
1877    /**
1878     * A one-to-many two-hop relation through an intermediate model.
1879     *
1880     * @template TRelated of Model
1881     *
1882     * @param  class-string<TRelated>  $related
1883     * @param  class-string<Model>  $through
1884     * @param  string|list<string>|null  $firstKey
1885     * @param  string|list<string>|null  $secondKey
1886     * @param  string|list<string>|null  $localKey
1887     * @return Relations\HasManyThrough<TRelated>
1888     * @throws \InvalidArgumentException
1889     */
1890    protected function hasManyThrough(
1891        string $related,
1892        string $through,
1893        string|array|null $firstKey = null,
1894        string|array|null $secondKey = null,
1895        string|array|null $localKey = null,
1896    ): Relations\HasManyThrough {
1897        $localKey ??= self::defaultLocalKey();
1898        $firstKey ??= self::defaultForeignKeyFor($localKey);
1899        $secondKey ??= self::defaultForeignKeyFrom($through);
1900
1901        self::assertColumnExists($through, $firstKey, 'first key');
1902        self::assertColumnExists($related, $secondKey, 'second key');
1903        self::assertColumnExists(static::class, $localKey, 'local key');
1904
1905        return (new Relations\HasManyThrough($this, $related, $through, $firstKey, $secondKey, $localKey))
1906            ->withName(self::relationName());
1907    }
1908
1909    /**
1910     * A one-to-many polymorphic relation: the related table's FK + type
1911     * columns point back at models of any class.
1912     *
1913     * @template TRelated of Model
1914     *
1915     * @param  class-string<TRelated>  $related
1916     * @param  string|null  $morphName
1917     * @param  string|null  $foreignKey
1918     * @param  string|null  $localKey
1919     * @param  string|null  $typeColumn
1920     * @return Relations\MorphMany<TRelated>
1921     * @throws \InvalidArgumentException
1922     */
1923    protected function morphMany(
1924        string $related,
1925        ?string $morphName = null,
1926        ?string $foreignKey = null,
1927        ?string $localKey = null,
1928        ?string $typeColumn = null,
1929    ): Relations\MorphMany {
1930        $localKey ??= self::defaultLocalKey();
1931
1932        if (is_array($localKey)) {
1933            throw new \LogicException(
1934                'morphMany() does not support composite keys; the morph (type, key) '
1935                . 'pair is a scalar-key convention.'
1936            );
1937        }
1938
1939        $foreignKey ??= self::defaultMorphForeignKey($morphName, $related, $foreignKey, $typeColumn);
1940        $typeColumn ??= self::defaultMorphTypeColumn($morphName, $related, $foreignKey);
1941
1942        self::assertColumnExists($related, $foreignKey, 'foreign key');
1943        self::assertColumnExists($related, $typeColumn, 'morph type');
1944        self::assertColumnExists(static::class, $localKey, 'local key');
1945        self::assertMorphKeyMatches($related, $foreignKey, static::class);
1946
1947        return (new Relations\MorphMany($this, $related, $foreignKey, $localKey, $typeColumn))
1948            ->withName(self::relationName());
1949    }
1950
1951    /**
1952     * A one-to-one polymorphic relation — the first row of a morphMany,
1953     * stably ordered by the related PK.
1954     *
1955     * @template TRelated of Model
1956     *
1957     * @param  class-string<TRelated>  $related
1958     * @param  string|null  $morphName
1959     * @param  string|null  $foreignKey
1960     * @param  string|null  $localKey
1961     * @param  string|null  $typeColumn
1962     * @return Relations\MorphOne<TRelated>
1963     * @throws \InvalidArgumentException
1964     */
1965    protected function morphOne(
1966        string $related,
1967        ?string $morphName = null,
1968        ?string $foreignKey = null,
1969        ?string $localKey = null,
1970        ?string $typeColumn = null,
1971    ): Relations\MorphOne {
1972        $localKey ??= self::defaultLocalKey();
1973
1974        if (is_array($localKey)) {
1975            throw new \LogicException(
1976                'morphOne() does not support composite keys; the morph (type, key) '
1977                . 'pair is a scalar-key convention.'
1978            );
1979        }
1980
1981        $foreignKey ??= self::defaultMorphForeignKey($morphName, $related, $foreignKey, $typeColumn);
1982        $typeColumn ??= self::defaultMorphTypeColumn($morphName, $related, $foreignKey);
1983
1984        self::assertColumnExists($related, $foreignKey, 'foreign key');
1985        self::assertColumnExists($related, $typeColumn, 'morph type');
1986        self::assertColumnExists(static::class, $localKey, 'local key');
1987        self::assertMorphKeyMatches($related, $foreignKey, static::class);
1988
1989        return (new Relations\MorphOne($this, $related, $foreignKey, $localKey, $typeColumn))
1990            ->withName(self::relationName());
1991    }
1992
1993    /**
1994     * The inverse polymorphic relation: this model's (type, key) pair
1995     * points at a row of any model table, resolved per row from the type
1996     * column.
1997     *
1998     * @template TRelated of Model The classes the allowlist admits — inferred from `$types`.
1999     *
2000     * @param  string|null  $morphName
2001     * @param  string|null  $typeColumn
2002     * @param  string|null  $foreignKey
2003     * @param  string|null  $ownerKey
2004     * @param  list<class-string<TRelated>>|null  $types  The optional morph-alias allowlist; null resolves any model class.
2005     * @return ($types is null ? Relations\MorphTo<Model> : Relations\MorphTo<TRelated>)
2006     * @throws \InvalidArgumentException
2007     */
2008    protected function morphTo(
2009        ?string $morphName = null,
2010        ?string $typeColumn = null,
2011        ?string $foreignKey = null,
2012        ?string $ownerKey = null,
2013        ?array $types = null,
2014    ): Relations\MorphTo {
2015        $typeColumn ??= self::defaultMorphTypeColumn($morphName, static::class, $typeColumn);
2016        $foreignKey ??= self::defaultMorphForeignKey($morphName, static::class, $foreignKey, $typeColumn);
2017        $ownerKey ??= 'id';
2018
2019        self::assertColumnExists(static::class, $typeColumn, 'morph type');
2020        self::assertColumnExists(static::class, $foreignKey, 'foreign key');
2021
2022        return (new Relations\MorphTo($this, $typeColumn, $foreignKey, $ownerKey, $types))
2023            ->withName(self::relationName());
2024    }
2025
2026    /**
2027     * The morph FK default: `{morphName}_id`, or the caller's explicit
2028     * type column's `_type` → `_id` mirror.
2029     *
2030     * @param  string|null  $morphName
2031     * @param  class-string<Model>  $model
2032     * @param  string|null  $foreignKey
2033     * @param  string|null  $typeColumn
2034     * @return string
2035     * @throws \InvalidArgumentException
2036     */
2037    private static function defaultMorphForeignKey(
2038        ?string $morphName,
2039        string $model,
2040        ?string $foreignKey,
2041        ?string $typeColumn,
2042    ): string {
2043        if ($morphName !== null && $morphName !== '') {
2044            return $morphName . '_id';
2045        }
2046
2047        if ($typeColumn !== null && str_ends_with($typeColumn, '_type')) {
2048            return substr($typeColumn, 0, -strlen('_type')) . '_id';
2049        }
2050
2051        throw new \InvalidArgumentException(
2052            "A morph relation on [{$model}] needs a morph name (or an explicit "
2053            . '`_type`-suffixed type column) to derive its column names.'
2054        );
2055    }
2056
2057    /**
2058     * The morph type-column default: `{morphName}_type`, or the caller's
2059     * explicit FK column's `_id` → `_type` mirror.
2060     *
2061     * @param  string|null  $morphName
2062     * @param  class-string<Model>  $model
2063     * @param  string|null  $foreignKey
2064     * @return string
2065     * @throws \InvalidArgumentException
2066     */
2067    private static function defaultMorphTypeColumn(
2068        ?string $morphName,
2069        string $model,
2070        ?string $foreignKey,
2071    ): string {
2072        if ($morphName !== null && $morphName !== '') {
2073            return $morphName . '_type';
2074        }
2075
2076        if ($foreignKey !== null && str_ends_with($foreignKey, '_id')) {
2077            return substr($foreignKey, 0, -strlen('_id')) . '_type';
2078        }
2079
2080        throw new \InvalidArgumentException(
2081            "A morph relation on [{$model}] needs a morph name (or an explicit "
2082            . '`_id`-suffixed foreign key) to derive its column names.'
2083        );
2084    }
2085
2086    /**
2087     * A many-to-many relation through a pivot table.
2088     *
2089     * @template TRelated of Model
2090     *
2091     * @param  class-string<TRelated>  $related
2092     * @param  string|class-string<Model>|null  $table  The pivot table name, or a model class-string to derive it.
2093     * @param  string|null  $foreignPivotKey
2094     * @param  string|null  $relatedPivotKey
2095     * @param  string|null  $parentKey
2096     * @param  string|null  $relatedKey
2097     * @return Relations\BelongsToMany<TRelated>
2098     * @throws \InvalidArgumentException
2099     */
2100    protected function belongsToMany(
2101        string $related,
2102        ?string $table = null,
2103        ?string $foreignPivotKey = null,
2104        ?string $relatedPivotKey = null,
2105        ?string $parentKey = null,
2106        ?string $relatedKey = null,
2107    ): Relations\BelongsToMany {
2108        return (new Relations\BelongsToMany(
2109            $this,
2110            $related,
2111            $table,
2112            $foreignPivotKey,
2113            $relatedPivotKey,
2114            $parentKey,
2115            $relatedKey,
2116        ))->withName(self::relationName());
2117    }
2118
2119    /**
2120     * A many-to-many polymorphic relation: the pivot's parent side is a
2121     * (type, key) pair, so models of any class share the pool.
2122     *
2123     * @template TRelated of Model
2124     * @template TPool of Model
2125     *
2126     * @param  class-string<TRelated>  $related
2127     * @param  string  $morphName
2128     * @param  string|class-string<Model>|null  $table  The pivot table name, or a model class-string to derive it.
2129     * @param  list<class-string<TPool>>|null  $poolTypes  The pool allowlist for the inverse direction's `pool()` read.
2130     * @return ($poolTypes is null ? Relations\MorphToMany<TRelated, Model> : Relations\MorphToMany<TRelated, TPool>)
2131     * @throws \InvalidArgumentException
2132     */
2133    protected function morphToMany(
2134        string $related,
2135        string $morphName,
2136        ?string $table = null,
2137        ?array $poolTypes = null,
2138    ): Relations\MorphToMany {
2139        return (new Relations\MorphToMany($this, $related, $morphName, $table, poolTypes: $poolTypes))
2140            ->withName(self::relationName());
2141    }
2142
2143    /**
2144     * The inverse polymorphic many-to-many relation: this model is the
2145     * related side of the pivot.
2146     *
2147     * @template TRelated of Model
2148     * @template TPool of Model
2149     *
2150     * @param  class-string<TRelated>  $related
2151     * @param  string  $morphName
2152     * @param  string|class-string<Model>|null  $table  The pivot table name, or a model class-string to derive it.
2153     * @param  list<class-string<TPool>>|null  $poolTypes  The pool allowlist; narrows `pool()` to exactly those classes.
2154     * @return ($poolTypes is null ? Relations\MorphToMany<TRelated, Model> : Relations\MorphToMany<TRelated, TPool>)
2155     * @throws \InvalidArgumentException
2156     */
2157    protected function morphedByMany(
2158        string $related,
2159        string $morphName,
2160        ?string $table = null,
2161        ?array $poolTypes = null,
2162    ): Relations\MorphToMany {
2163        return (new Relations\MorphToMany($this, $related, $morphName, $table, inverse: true, poolTypes: $poolTypes))
2164            ->withName(self::relationName());
2165    }
2166
2167    /**
2168     * Cache a relation's loaded result on the instance.
2169     *
2170     * @param  string  $name
2171     * @param  Model|Collection<int, Model>|null  $value
2172     * @return static
2173     */
2174    final public function setRelation(string $name, Model|Collection|null $value): static
2175    {
2176        $this->relations[$name] = $value;
2177
2178        return $this;
2179    }
2180
2181    /**
2182     * A loaded relation's cached result — the loader's read path.
2183     *
2184     * @param  string  $name
2185     * @return Model|Collection<int, Model>|null
2186     */
2187    final public function cachedRelation(string $name): Model|Collection|null
2188    {
2189        $value = $this->relations[$name] ?? null;
2190
2191        if (!$value instanceof Model && !$value instanceof Collection) {
2192            return null;
2193        }
2194
2195        return $value;
2196    }
2197
2198    /**
2199     * The relation-method name the current factory call came from.
2200     *
2201     * A bounded backtrace walk skips every frame inside the ORM's own
2202     * namespace and takes the first frame outside it; a factory reached
2203     * from anywhere else stamps nothing and the cache path stays off.
2204     *
2205     * @return string|null
2206     */
2207    private static function relationName(): string|null
2208    {
2209        $ormNamespace = __NAMESPACE__ . '\\';
2210        // This repo's fixtures are user-model stand-ins — not plumbing.
2211        $exemptNamespace = __NAMESPACE__ . '\\Tests\\';
2212
2213        // Frame 0 is relationName() itself, frame 1 the factory — the
2214        // caller of interest is frame 2 and up.
2215        $frames = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6);
2216
2217        foreach (array_slice($frames, 2) as $frame) {
2218            $class = $frame['class'] ?? null;
2219
2220            if (is_string($class)) {
2221                $isFramework = str_starts_with($class, $ormNamespace)
2222                    && !str_starts_with($class, $exemptNamespace);
2223
2224                if (!$isFramework) {
2225                    return $frame['function'];
2226                }
2227
2228                continue;
2229            }
2230
2231            // A class-less frame is a plain function — never a relation
2232            // method (those are class methods), so the factory was not
2233            // called from one.
2234            return null;
2235        }
2236
2237        return null;
2238    }
2239
2240    /**
2241     * Whether a relation has been (eager-)loaded on this instance.
2242     *
2243     * @param  string  $name
2244     * @return bool
2245     */
2246    final public function relationLoaded(string $name): bool
2247    {
2248        return array_key_exists($name, $this->relations);
2249    }
2250
2251    /**
2252     * The FK default for a relation keyed off `$localKey` (this model's).
2253     *
2254     * @param  string|list<string>  $localKey
2255     * @return string
2256     * @throws \LogicException
2257     */
2258    private static function defaultForeignKeyFor(string|array $localKey): string
2259    {
2260        self::assertDerivableKey($localKey);
2261
2262        return self::defaultForeignKey();
2263    }
2264
2265    /**
2266     * The FK default for a belongsTo keyed off `$ownerKey` (pointing at
2267     * `$related`).
2268     *
2269     * @param  string|list<string>  $ownerKey
2270     * @param  class-string<Model>  $related
2271     * @return string
2272     * @throws \LogicException
2273     */
2274    private static function defaultForeignKeyFromKey(string|array $ownerKey, string $related): string
2275    {
2276        self::assertDerivableKey($ownerKey);
2277
2278        return self::defaultForeignKeyFrom($related);
2279    }
2280
2281    /**
2282     * Fail fast when a composite key cannot derive its FK counterpart.
2283     *
2284     * @param  string|list<string>  $key
2285     * @return void
2286     * @throws \LogicException
2287     */
2288    private static function assertDerivableKey(string|array $key): void
2289    {
2290        if (is_array($key)) {
2291            throw new \LogicException(
2292                'A relation over a composite key cannot derive its counterpart columns by '
2293                . 'convention; declare both sides explicitly as matching column lists, e.g. '
2294                . 'hasMany(Post::class, [\'region_id\', \'country\']).'
2295            );
2296        }
2297    }
2298
2299    /**
2300     * The snake_case foreign-key default for this model: its short class
2301     * name + `_id`.
2302     *
2303     * @return string
2304     */
2305    private static function defaultForeignKey(): string
2306    {
2307        $short = (new \ReflectionClass(static::class))->getShortName();
2308
2309        return strtolower((string) preg_replace('/(?<=[a-z0-9])([A-Z])/', '_$1', $short)) . '_id';
2310    }
2311
2312    /**
2313     * The snake_case foreign-key default pointing at another model: that
2314     * model's short class name + `_id`.
2315     *
2316     * @param  class-string<Model>  $related
2317     * @return string
2318     */
2319    private static function defaultForeignKeyFrom(string $related): string
2320    {
2321        $short = (new \ReflectionClass($related))->getShortName();
2322
2323        return strtolower((string) preg_replace('/(?<=[a-z0-9])([A-Z])/', '_$1', $short)) . '_id';
2324    }
2325
2326    /**
2327     * This model's primary-key column(s) (the local-key default).
2328     *
2329     * @return string|list<string>
2330     * @throws \LogicException
2331     */
2332    private static function defaultLocalKey(): string|array
2333    {
2334        return self::primaryKeyNamesOf(static::class);
2335    }
2336
2337    /**
2338     * Another model's primary-key column(s) (the owner-key default).
2339     *
2340     * @param  class-string<Model>  $related
2341     * @return string|list<string>
2342     * @throws \LogicException
2343     */
2344    private static function defaultLocalKeyOf(string $related): string|array
2345    {
2346        return self::primaryKeyNamesOf($related);
2347    }
2348
2349    /**
2350     * A model's primary-key column name(s).
2351     *
2352     * @param  class-string<Model>  $class
2353     * @return string|list<string>
2354     * @throws \LogicException
2355     */
2356    private static function primaryKeyNamesOf(string $class): string|array
2357    {
2358        $keys = MetadataFactory::for($class)->primaryKeys;
2359
2360        if ($keys === []) {
2361            throw new \LogicException(
2362                "Relation endpoints require a primary key; model [{$class}] declares none."
2363            );
2364        }
2365
2366        $names = [];
2367
2368        foreach ($keys as $key) {
2369            if ($key->name === null) {
2370                throw new \LogicException(
2371                    "Relation endpoints require a named primary key; model [{$class}] has one without a name."
2372                );
2373            }
2374
2375            $names[] = $key->name;
2376        }
2377
2378        return count($names) === 1 ? $names[0] : $names;
2379    }
2380
2381    /**
2382     * Fail fast when a morph key column's type cannot hold the primary
2383     * key it stores.
2384     *
2385     * @param  class-string<Model>  $holder  The model whose table carries the `{name}_id` column.
2386     * @param  string  $keyColumn
2387     * @param  class-string<Model>  $target  The model whose primary key the column must hold.
2388     * @return void
2389     * @throws \InvalidArgumentException
2390     */
2391    private static function assertMorphKeyMatches(string $holder, string $keyColumn, string $target): void
2392    {
2393        $declared = MetadataFactory::for($holder)->mappingFor($keyColumn)->column->type;
2394        $primaryKey = self::singlePrimaryKeyOf($target);
2395
2396        if ($primaryKey->type !== $declared) {
2397            throw new \InvalidArgumentException(
2398                "The morph key column [{$keyColumn}] on [{$holder}] is [{$declared->value}], but "
2399                . "[{$target}]'s primary key is [{$primaryKey->type->value}]. A morph pair can "
2400                . 'only point at models whose primary-key type matches the key column — '
2401                . 'declare the pair with a matching keyType (or uuidMorphs()).'
2402            );
2403        }
2404    }
2405
2406    /**
2407     * A model's single named primary-key column.
2408     *
2409     * @param  class-string<Model>  $class
2410     * @return Column
2411     * @throws \LogicException
2412     */
2413    private static function singlePrimaryKeyOf(string $class): Column
2414    {
2415        $keys = MetadataFactory::for($class)->primaryKeys;
2416
2417        if (count($keys) !== 1 || $keys[0]->name === null) {
2418            throw new \LogicException(
2419                "A morph target requires a single named primary key; model [{$class}] "
2420                . 'declares none, a composite key, or an unnamed key.'
2421            );
2422        }
2423
2424        return $keys[0];
2425    }
2426
2427    /**
2428     * Fail fast when a column does not exist on a model.
2429     *
2430     * @param  class-string<Model>  $model
2431     * @param  string|list<string>  $column
2432     * @param  string  $role
2433     * @return void
2434     * @throws \InvalidArgumentException
2435     */
2436    private static function assertColumnExists(string $model, string|array $column, string $role): void
2437    {
2438        foreach (is_array($column) ? $column : [$column] as $name) {
2439            self::assertSingleColumnExists($model, $name, $role);
2440        }
2441    }
2442
2443    /**
2444     * Fail fast when one column does not exist on a model.
2445     *
2446     * @param  class-string<Model>  $model
2447     * @param  string  $column
2448     * @param  string  $role
2449     * @return void
2450     * @throws \InvalidArgumentException
2451     */
2452    private static function assertSingleColumnExists(string $model, string $column, string $role): void
2453    {
2454        $metadata = MetadataFactory::for($model);
2455
2456        foreach ($metadata->properties as $mapping) {
2457            if ($mapping->columnName === $column) {
2458                return;
2459            }
2460        }
2461
2462        throw new \InvalidArgumentException(
2463            "Unknown {$role} column [{$column}] on model [{$model}]. A relation's columns "
2464            . 'must match the model\'s declared column names.'
2465        );
2466    }
2467}

From BlueprintAU\Radiant\Concerns\FiltersStaticQuery

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