Lines 97.58% 323 / 331
Functions and Methods 95.65% 44 / 46
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Blueprint 97.58% 323 / 331 95.65% 44 / 46 126 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 getTable 100.00% 1 / 1 100.00% 1 / 1 1
 forTable 100.00% 9 / 9 100.00% 1 / 1 1
 onlyColumns 100.00% 6 / 6 100.00% 1 / 1 1
 renamedFrom 100.00% 5 / 5 100.00% 1 / 1 2
 getRenamedFrom 100.00% 1 / 1 100.00% 1 / 1 1
 renameColumn 100.00% 9 / 9 100.00% 1 / 1 4
 getColumnRenames 100.00% 1 / 1 100.00% 1 / 1 1
 deriveIndexName 100.00% 1 / 1 100.00% 1 / 1 2
 normalizeForeignReference 77.77% 21 / 27 0.00% 0 / 1 7.54
 index 100.00% 18 / 18 100.00% 1 / 1 6
 column 100.00% 30 / 30 100.00% 1 / 1 8
 id 100.00% 1 / 1 100.00% 1 / 1 1
 string 100.00% 1 / 1 100.00% 1 / 1 1
 char 100.00% 1 / 1 100.00% 1 / 1 1
 text 100.00% 1 / 1 100.00% 1 / 1 1
 decimal 100.00% 1 / 1 100.00% 1 / 1 1
 date 100.00% 1 / 1 100.00% 1 / 1 1
 binary 100.00% 1 / 1 100.00% 1 / 1 1
 uuid 100.00% 1 / 1 100.00% 1 / 1 1
 enum 100.00% 10 / 10 100.00% 1 / 1 4
 assertColumnOptions 100.00% 19 / 19 100.00% 1 / 1 14
 timestamp 100.00% 2 / 2 100.00% 1 / 1 1
 datetime 100.00% 1 / 1 100.00% 1 / 1 1
 timestamps 100.00% 3 / 3 100.00% 1 / 1 1
 softDeletes 100.00% 1 / 1 100.00% 1 / 1 1
 assertPrecision 100.00% 5 / 5 100.00% 1 / 1 4
 foreignId 100.00% 1 / 1 100.00% 1 / 1 1
 morphs 100.00% 5 / 5 100.00% 1 / 1 2
 uuidMorphs 100.00% 1 / 1 100.00% 1 / 1 1
 foreignKey 100.00% 23 / 23 100.00% 1 / 1 10
 check 100.00% 8 / 8 100.00% 1 / 1 2
 deriveCheckName 100.00% 9 / 9 100.00% 1 / 1 4
 getChecks 100.00% 1 / 1 100.00% 1 / 1 1
 getForeignKeys 100.00% 15 / 15 100.00% 1 / 1 3
 dropColumn 100.00% 3 / 3 100.00% 1 / 1 1
 backfill 100.00% 3 / 3 100.00% 1 / 1 1
 getBackfills 100.00% 1 / 1 100.00% 1 / 1 1
 dropForeignKey 100.00% 3 / 3 100.00% 1 / 1 1
 dropCheck 100.00% 3 / 3 100.00% 1 / 1 1
 getDropForeignKeys 100.00% 1 / 1 100.00% 1 / 1 1
 getDropChecks 100.00% 1 / 1 100.00% 1 / 1 1
 getColumns 100.00% 1 / 1 100.00% 1 / 1 1
 getDropColumns 100.00% 1 / 1 100.00% 1 / 1 1
 getIndexes 100.00% 1 / 1 100.00% 1 / 1 1
 fromMetadata 97.82% 90 / 92 0.00% 0 / 1 23
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Schema;
6
7use BlueprintAU\Radiant\Attributes\Column;
8use BlueprintAU\Radiant\Attributes\ReferenceResolver;
9use BlueprintAU\Radiant\Database\Schema\Enums\ColumnType;
10use BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction;
11use BlueprintAU\Radiant\Model;
12use BlueprintAU\Radiant\Metadata\MetadataFactory;
13
14/**
15 * A fluent column definition for a `CREATE TABLE` / `ALTER TABLE`.
16 *
17 * Uses the same field vocabulary the ORM's `#[Column]` attribute uses
18 * (type, length, nullable, unique, index, foreign, â€¦) so a model's
19 * metadata can drive DDL directly.
20 *
21 * @phpstan-type ColumnShape array{
22 *     type: ColumnType,
23 *     name: string,
24 *     primaryKey: bool,
25 *     autoIncrement: bool,
26 *     nullable: bool,
27 *     unique: bool,
28 *     index: bool,
29 *     length: int|null,
30 *     precision: int|null,
31 *     scale: int|null,
32 *     values: list<string>|null,
33 *     default: mixed,
34 *     foreign: string|null,
35 *     onDelete: ForeignKeyAction|null,
36 *     onUpdate: ForeignKeyAction|null,
37 * }
38 */
39final class Blueprint
40{
41    /**
42     * The table this blueprint builds.
43     *
44     * @var string
45     */
46    private readonly string $table;
47
48    /**
49     * The table this blueprint renames, when it declares a table rename.
50     *
51     * @var string|null
52     */
53    private string|null $renamedFrom = null;
54
55    /**
56     * The column renames declared on this blueprint.
57     *
58     * @var list<array{from: string, to: string}>
59     */
60    private array $columnRenames = [];
61
62    /**
63     * Create a table-bound blueprint.
64     *
65     * @param  string  $table
66     */
67    public function __construct(string $table)
68    {
69        $this->table = $table;
70    }
71
72    /**
73     * The table this blueprint builds.
74     *
75     * @return string
76     */
77    final public function getTable(): string
78    {
79        return $this->table;
80    }
81
82    /**
83     * A copy of this blueprint bound to a different table name.
84     *
85     * Indexes are not carried â€” derived index names embed the table name,
86     * and the rebuild re-creates them from the original blueprint after
87     * the rename.
88     *
89     * @param  string  $table
90     * @return static
91     */
92    public function forTable(string $table): static
93    {
94        // `$table` is readonly, so the rebind goes through the
95        // constructor rather than a clone-assign.
96        $copy = new static($table);
97
98        $copy->columns = $this->columns;
99        $copy->foreignKeys = $this->foreignKeys;
100        $copy->checks = $this->checks;
101        $copy->columnRenames = $this->columnRenames;
102        $copy->renamedFrom = $this->renamedFrom;
103        $copy->backfills = $this->backfills;
104
105        $copy->indexes = [];
106
107        return $copy;
108    }
109
110    /**
111     * A copy of this blueprint holding only the named columns.
112     *
113     * Constraints, indexes and renames are not carried: a subset is a
114     * column list, not a table.
115     *
116     * @param  list<string>  $names
117     * @return static
118     */
119    public function onlyColumns(array $names): static
120    {
121        $copy = new static($this->table);
122
123        $copy->columns = array_values(array_filter(
124            $this->columns,
125            fn (array $column) => in_array($column['name'], $names, true),
126        ));
127
128        return $copy;
129    }
130
131    /**
132     * Declare that this blueprint renames an existing table.
133     *
134     * @param  string  $oldTable
135     * @return static
136     *
137     * @throws \InvalidArgumentException
138     */
139    public function renamedFrom(string $oldTable): static
140    {
141        if (trim($oldTable) === '') {
142            throw new \InvalidArgumentException('A table rename requires a non-empty old table name.');
143        }
144
145        $clone = clone $this;
146        $clone->renamedFrom = $oldTable;
147        return $clone;
148    }
149
150    /**
151     * The table this blueprint renames, or null when it is not a rename.
152     *
153     * @return string|null
154     */
155    final public function getRenamedFrom(): string|null
156    {
157        return $this->renamedFrom;
158    }
159
160    /**
161     * Declare a column rename: the live column `$from` becomes `$to`.
162     *
163     * @param  string  $from
164     * @param  string  $to
165     * @return static
166     *
167     * @throws \InvalidArgumentException
168     */
169    public function renameColumn(string $from, string $to): static
170    {
171        if (trim($from) === '' || trim($to) === '') {
172            throw new \InvalidArgumentException('A column rename requires non-empty column names.');
173        }
174
175        if ($from === $to) {
176            throw new \InvalidArgumentException(
177                "A column rename requires different names; got [{$from}] -> [{$to}]."
178            );
179        }
180
181        $clone = clone $this;
182        $clone->columnRenames = [...$this->columnRenames, ['from' => $from, 'to' => $to]];
183        return $clone;
184    }
185
186    /**
187     * The declared column renames, in declaration order.
188     *
189     * @return list<array{from: string, to: string}>
190     */
191    final public function getColumnRenames(): array
192    {
193        return $this->columnRenames;
194    }
195
196    /**
197     * The columns to create, in declaration order.
198     *
199     * @var list<ColumnShape>
200     */
201    private array $columns = [];
202
203    /**
204     * The columns to drop (ALTER only).
205     *
206     * @var list<string>
207     */
208    private array $dropColumns = [];
209
210    /**
211     * The explicit backfill values for added NOT NULL columns (ALTER only).
212     *
213     * @var array<string, mixed>
214     */
215    private array $backfills = [];
216
217    /**
218     * Live foreign-key constraint names to drop (ALTER only) â€” the drop
219     * handles captured by the inspector during diffing.
220     *
221     * @var list<string>
222     */
223    private array $dropForeignKeys = [];
224
225    /**
226     * Live CHECK constraint names to drop (ALTER only) â€” the drop
227     * handles captured by the inspector during diffing.
228     *
229     * @var list<string>
230     */
231    private array $dropChecks = [];
232
233    /**
234     * Indexes (single or composite), each with its final name.
235     *
236     * @var list<array{name: string, columns: list<string>, unique: bool, where: string|null, nullsNotDistinct: bool}>
237     */
238    private array $indexes = [];
239
240    /**
241     * Derive the final index name from its kind and columns.
242     *
243     * @param  list<string>  $columns
244     * @param  bool  $unique
245     * @return string
246     */
247    private function deriveIndexName(array $columns, bool $unique): string
248    {
249        return ConstraintNamer::derive($this->table, $columns, $unique ? 'unique' : 'index');
250    }
251
252    /**
253     * Normalize a `foreign` reference to its `table.column` form.
254     *
255     * @param  class-string<\BlueprintAU\Radiant\Model>|string  $foreign
256     * @param  string  $column
257     * @return string
258     *
259     * @throws \InvalidArgumentException
260     */
261    private function normalizeForeignReference(string $foreign, string $column): string
262    {
263        // Already explicit `table.column`.
264        if (str_contains($foreign, '.')) {
265            if (count(explode('.', $foreign)) !== 2) {
266                throw new \InvalidArgumentException(
267                    'Foreign key reference must be "table.column"; got ' . $foreign . '.'
268                );
269            }
270
271            return $foreign;
272        }
273
274        $table = ReferenceResolver::resolve($foreign);
275
276        // The referenced model's single PK (if declared) gives the column;
277        // fall back to the `id` convention for tables the ORM does not own.
278        $pkColumn = 'id';
279
280        if (str_contains($foreign, '\\')) {
281            if (!is_a($foreign, Model::class, true)) {
282                throw new \LogicException(
283                    "Reference [{$foreign}] resolved as a model but is not one."
284                );
285            }
286
287            $pks = MetadataFactory::for($foreign)->primaryKeys;
288
289            if (count($pks) === 1) {
290                $pkColumn = $pks[0]->name ?? $pkColumn;
291            } elseif (count($pks) > 1) {
292                throw new \InvalidArgumentException(sprintf(
293                    'Column [%s] references model [%s], which has a composite primary '
294                    . 'key; a single-column foreign key cannot reference it. Declare a '
295                    . 'class-level #[ForeignKey(columns: [...], references: %s::class)] '
296                    . 'with the full column list instead.',
297                    $column,
298                    $foreign,
299                    (new \ReflectionClass($foreign))->getShortName(),
300                ));
301            }
302        }
303
304        return $table . '.' . $pkColumn;
305    }
306
307    /**
308     * Add an index over one or more columns.
309     *
310     * When `$name` is given it is the whole final name; when omitted the
311     * name is derived as `{table}_{columns}_{kind}`.
312     *
313     * @param  string|null  $name
314     * @param  list<string>  $columns
315     * @param  bool  $unique
316     * @param  string|null  $where  Partial-index predicate (Postgres, SQLite).
317     * @param  bool  $nullsNotDistinct  `NULLS NOT DISTINCT` (Postgres 15+).
318     * @return static
319     *
320     * @throws \InvalidArgumentException
321     */
322    public function index(
323        ?string $name,
324        array $columns,
325        bool $unique = false,
326        ?string $where = null,
327        bool $nullsNotDistinct = false,
328    ): static {
329        if ($columns === []) {
330            throw new \InvalidArgumentException('An index requires at least one column.');
331        }
332
333        if ($where !== null && trim($where) === '') {
334            throw new \InvalidArgumentException('An index `where` predicate, when given, must be non-empty.');
335        }
336
337        if ($nullsNotDistinct && !$unique) {
338            throw new \InvalidArgumentException(
339                'An index declares nullsNotDistinct without unique: NULLS NOT DISTINCT '
340                . 'only applies to a UNIQUE index.'
341            );
342        }
343
344        $clone = clone $this;
345        $clone->indexes = [...$this->indexes, [
346            'name' => $name ?? $this->deriveIndexName($columns, $unique),
347            'columns' => $columns,
348            'unique' => $unique,
349            'where' => $where,
350            'nullsNotDistinct' => $nullsNotDistinct,
351        ]];
352        return $clone;
353    }
354
355    /**
356     * Add a column to the table.
357     *
358     * @param  ColumnType  $type
359     * @param  string  $name
360     * @param  bool  $primaryKey
361     * @param  bool  $autoIncrement
362     * @param  bool  $nullable
363     * @param  bool  $unique
364     * @param  bool  $index
365     * @param  int|null  $length
366     * @param  int|null  $precision  Fractional-seconds digits (1–6) for datetime columns; total digits for decimal columns.
367     * @param  int|null  $scale  Fractional digits for a decimal column (0–`precision`).
368 * @param  list<string>|null  $values  The allowed values for an enum column.
369     * @param  mixed  $default
370     * @param  string|null  $foreign
371     * @param  ForeignKeyAction|string|null  $onDelete
372     * @param  ForeignKeyAction|string|null  $onUpdate
373     * @return static
374     */
375    public function column(
376        ColumnType $type,
377        string $name,
378        bool $primaryKey = false,
379        bool $autoIncrement = false,
380        bool $nullable = false,
381        bool $unique = false,
382        bool $index = false,
383        ?int $length = null,
384        ?int $precision = null,
385        ?int $scale = null,
386        array|null $values = null,
387        mixed $default = null,
388        ?string $foreign = null,
389        ForeignKeyAction|string|null $onDelete = null,
390        ForeignKeyAction|string|null $onUpdate = null,
391    ): static {
392        // Validate the reference at declaration time, and normalize it to
393        // `table.column` so the getter is a pure read.
394        if ($foreign !== null) {
395            $foreign = $this->normalizeForeignReference($foreign, $name);
396        }
397
398        self::assertColumnOptions($type, $length, $precision, $scale, $values);
399
400        $clone = clone $this;
401        $clone->columns = [...$this->columns, [
402            'type' => $type,
403            'name' => $name,
404            'primaryKey' => $primaryKey,
405            'autoIncrement' => $autoIncrement,
406            'nullable' => $nullable,
407            'unique' => $unique,
408            'index' => $index,
409            'length' => $length,
410            'precision' => $precision,
411            'scale' => $scale,
412            'values' => $values,
413            'default' => $default,
414            'foreign' => $foreign,
415            'onDelete' => $onDelete === null ? null : ($onDelete instanceof ForeignKeyAction ? $onDelete : ForeignKeyAction::fromChecked($onDelete)),
416            'onUpdate' => $onUpdate === null ? null : ($onUpdate instanceof ForeignKeyAction ? $onUpdate : ForeignKeyAction::fromChecked($onUpdate)),
417        ]];
418
419        // A flagged plain index derives its final name here (`unique: true`
420        // rides the column's inline UNIQUE constraint, so no separate
421        // index is needed).
422        if ($index === true && $unique !== true) {
423            $clone->indexes = [...$clone->indexes, [
424                'name' => $this->deriveIndexName([$name], false),
425                'columns' => [$name],
426                'unique' => false,
427                'where' => null,
428                'nullsNotDistinct' => false,
429            ]];
430        }
431
432        return $clone;
433    }
434
435    /**
436     * Add a primary-key column.
437     *
438     * @param  string  $name
439     * @param  ColumnType  $type
440     * @param  bool  $autoIncrement
441     * @return static
442     */
443    public function id(string $name = 'id', ColumnType $type = ColumnType::BigInt, bool $autoIncrement = true): static
444    {
445        return $this->column($type, $name, primaryKey: true, autoIncrement: $autoIncrement);
446    }
447
448    /**
449     * Add a string column.
450     *
451     * @param  string  $name
452     * @param  int  $length
453     * @return static
454     */
455    public function string(string $name, int $length): static
456    {
457        return $this->column(ColumnType::String, $name, length: $length);
458    }
459
460    /**
461     * Add a fixed-length string column.
462     *
463     * @param  string  $name
464     * @param  int  $length
465     * @return static
466     */
467    public function char(string $name, int $length): static
468    {
469        return $this->column(ColumnType::Char, $name, length: $length);
470    }
471
472    /**
473     * Add an unbounded text column.
474     *
475     * @param  string  $name
476     * @return static
477     */
478    public function text(string $name): static
479    {
480        return $this->column(ColumnType::Text, $name);
481    }
482
483    /**
484     * Add an exact fixed-point decimal column.
485     *
486     * @param  string  $name
487     * @param  int  $precision  Total digits (1–65).
488     * @param  int  $scale  Fractional digits (0–`$precision`).
489     * @return static
490     *
491     * @throws \InvalidArgumentException
492     */
493    public function decimal(string $name, int $precision, int $scale): static
494    {
495        return $this->column(ColumnType::Decimal, $name, precision: $precision, scale: $scale);
496    }
497
498    /**
499     * Add a calendar date column (no time component).
500     *
501     * @param  string  $name
502     * @return static
503     */
504    public function date(string $name): static
505    {
506        return $this->column(ColumnType::Date, $name);
507    }
508
509    /**
510     * Add a raw binary column.
511     *
512     * @param  string  $name
513     * @param  int|null  $length  Optional byte cap (MySQL renders `varbinary(n)`).
514     * @return static
515     */
516    public function binary(string $name, ?int $length = null): static
517    {
518        return $this->column(ColumnType::Binary, $name, length: $length);
519    }
520
521    /**
522     * Add a RFC 4122 UUID column (fixed 36 characters).
523     *
524     * @param  string  $name
525     * @return static
526     */
527    public function uuid(string $name): static
528    {
529        return $this->column(ColumnType::Uuid, $name);
530    }
531
532    /**
533     * Add a constrained string column â€” a sized string plus an inline
534     * CHECK over the allowed values.
535     *
536     * @param  string  $name
537     * @param  array<int, mixed>  $values  The allowed values (non-empty, all strings â€” runtime-validated).
538     * @param  int|null  $length  Optional storage length; defaults to the longest value.
539     * @return static
540     *
541     * @throws \InvalidArgumentException
542     */
543    public function enum(string $name, array $values, ?int $length = null): static
544    {
545        if ($values === []) {
546            throw new \InvalidArgumentException("An enum column [{$name}] requires at least one value.");
547        }
548
549        foreach ($values as $value) {
550            if (!is_string($value)) {
551                throw new \InvalidArgumentException(
552                    "An enum column [{$name}] requires string values; got " . get_debug_type($value) . '.'
553                );
554            }
555        }
556
557        $length ??= max(array_map(strlen(...), $values));
558
559        /** @var list<string> $validated */
560        $validated = array_values($values);
561
562        return $this->column(ColumnType::Enum, $name, length: $length, values: $validated);
563    }
564
565    /**
566     * Assert the type-specific options a column declares are usable.
567     *
568     * @param  ColumnType  $type
569     * @param  int|null  $length
570     * @param  int|null  $precision
571     * @param  int|null  $scale
572     * @param  list<string>|null  $values
573     * @return void
574     *
575     * @throws \InvalidArgumentException
576     */
577    private static function assertColumnOptions(ColumnType $type, ?int $length, ?int $precision, ?int $scale, ?array $values): void
578    {
579        if ($type === ColumnType::Decimal) {
580            if ($precision === null || $precision < 1 || $precision > 65) {
581                throw new \InvalidArgumentException(
582                    "A decimal column requires a precision between 1 and 65; got "
583                    . ($precision === null ? 'none' : $precision) . "."
584                );
585            }
586
587            if ($scale === null || $scale < 0 || $scale > $precision) {
588                throw new \InvalidArgumentException(
589                    "A decimal column requires a scale between 0 and its precision [{$precision}]; got "
590                    . ($scale === null ? 'none' : $scale) . "."
591                );
592            }
593        }
594
595        if ($type === ColumnType::Uuid && $length !== null) {
596            throw new \InvalidArgumentException(
597                'A uuid column has a fixed 36-character form; do not declare a length.'
598            );
599        }
600
601        if ($type !== ColumnType::Enum && $values !== null) {
602            throw new \InvalidArgumentException(
603                "Only an enum column accepts values; got values on a [{$type->value}] column."
604            );
605        }
606    }
607
608    /**
609     * Add a nullable datetime column.
610     *
611     * @param  string  $name
612     * @param  int|null  $precision  Fractional-seconds digits (1–6); null stores whole seconds.
613     * @return static
614     *
615     * @throws \InvalidArgumentException
616     */
617    public function timestamp(string $name, ?int $precision = null): static
618    {
619        self::assertPrecision($precision);
620
621        return $this->column(ColumnType::DateTime, $name, nullable: true, precision: $precision);
622    }
623
624    /**
625     * Add a nullable datetime column (an explicit alias of `timestamp()`).
626     *
627     * @param  string  $name
628     * @param  int|null  $precision  Fractional-seconds digits (1–6); null stores whole seconds.
629     * @return static
630     *
631     * @throws \InvalidArgumentException
632     */
633    public function datetime(string $name, ?int $precision = null): static
634    {
635        return $this->timestamp($name, $precision);
636    }
637
638    /**
639     * Add `created_at` / `updated_at` datetime columns.
640     *
641     * @param  int|null  $precision  Fractional-seconds digits (1–6); null stores whole seconds.
642     * @param  bool  $nullable  Whether the columns allow null.
643     * @return static
644     *
645     * @throws \InvalidArgumentException
646     */
647    public function timestamps(?int $precision = null, bool $nullable = false): static
648    {
649        return $this
650            ->column(ColumnType::DateTime, 'created_at', nullable: $nullable, precision: $precision)
651            ->column(ColumnType::DateTime, 'updated_at', nullable: $nullable, precision: $precision);
652    }
653
654    /**
655     * Add a nullable `deleted_at` datetime column for soft deletes.
656     *
657     * @param  int|null  $precision  Fractional-seconds digits (1–6); null stores whole seconds.
658     * @return static
659     *
660     * @throws \InvalidArgumentException
661     */
662    public function softDeletes(?int $precision = null): static
663    {
664        return $this->timestamp('deleted_at', $precision);
665    }
666
667    /**
668     * Assert a fractional-seconds precision is in the portable range.
669     *
670     * MySQL and Postgres both accept 0–6 fractional digits; the schema
671     * layer treats `null` as "whole seconds" and rejects anything above 6
672     * (no portable dialect stores more) or below 1 (use `null`).
673     *
674     * @param  int|null  $precision
675     * @return void
676     *
677     * @throws \InvalidArgumentException
678     */
679    private static function assertPrecision(?int $precision): void
680    {
681        if ($precision !== null && ($precision < 1 || $precision > 6)) {
682            throw new \InvalidArgumentException(
683                "Datetime precision [{$precision}] is out of range; use null for whole "
684                . 'seconds or an integer between 1 and 6 for fractional seconds.'
685            );
686        }
687    }
688
689    /**
690     * Add a foreign-key column referencing another table.
691     *
692     * @param  string  $name
693     * @param  string  $references
694     * @param  ColumnType  $type
695     * @param  int|null  $length
696     * @param  ForeignKeyAction|string|null  $onDelete
697     * @param  ForeignKeyAction|string|null  $onUpdate
698     * @return static
699     */
700    public function foreignId(
701        string $name,
702        string $references,
703        ColumnType $type = ColumnType::BigInt,
704        ?int $length = null,
705        ForeignKeyAction|string|null $onDelete = null,
706        ForeignKeyAction|string|null $onUpdate = null,
707    ): static {
708        return $this->column($type, $name, length: $length, foreign: $references, onDelete: $onDelete, onUpdate: $onUpdate);
709    }
710
711    /**
712     * Add a polymorphic (morph) column pair: `{name}_type` + `{name}_id`.
713     *
714     * @param  string  $name
715     * @param  bool  $nullable
716     * @param  ColumnType  $keyType  The `{name}_id` column's type; every morph target's primary key must match it.
717     * @return static
718     *
719     * @throws \InvalidArgumentException
720     */
721    public function morphs(string $name, bool $nullable = false, ColumnType $keyType = ColumnType::BigInt): static
722    {
723        if ($name === '') {
724            throw new \InvalidArgumentException('A morph pair requires a non-empty name.');
725        }
726
727        return $this
728            ->column(ColumnType::String, $name . '_type', nullable: $nullable, length: 255)
729            ->column($keyType, $name . '_id', nullable: $nullable);
730    }
731
732    /**
733     * Add a polymorphic column pair keyed by UUID primary keys.
734     *
735     * @param  string  $name
736     * @param  bool  $nullable
737     * @return static
738     *
739     * @throws \InvalidArgumentException
740     */
741    public function uuidMorphs(string $name, bool $nullable = false): static
742    {
743        return $this->morphs($name, $nullable, ColumnType::Uuid);
744    }
745
746    /**
747     * The table-level foreign-key constraints declared on this blueprint.
748     *
749     * @var list<array{name: string, columns: list<string>, references: list<string>, onDelete: ForeignKeyAction|null, onUpdate: ForeignKeyAction|null, deferrable: bool, initiallyDeferred: bool}>
750     */
751    private array $foreignKeys = [];
752
753    /**
754     * The table-level CHECK constraints declared on this blueprint.
755     *
756     * @var list<array{name: string, expression: string}>
757     */
758    private array $checks = [];
759
760    /**
761     * Add a foreign-key constraint over one or more columns.
762     *
763     * Use this for composite foreign keys (e.g. a join table referencing
764     * a composite primary key).
765     *
766     * @param  list<string>  $columns
767     * @param  string  $referencesTable
768     * @param  list<string>  $referencesColumns
769     * @param  ForeignKeyAction|string|null  $onDelete
770     * @param  ForeignKeyAction|string|null  $onUpdate
771     * @param  bool  $deferrable  Postgres only.
772     * @param  bool  $initiallyDeferred  Postgres only; implies `$deferrable`.
773     * @return static
774     *
775     * @throws \InvalidArgumentException
776     */
777    public function foreignKey(
778        array $columns,
779        string $referencesTable,
780        array $referencesColumns,
781        ForeignKeyAction|string|null $onDelete = null,
782        ForeignKeyAction|string|null $onUpdate = null,
783        bool $deferrable = false,
784        bool $initiallyDeferred = false,
785    ): static {
786        if ($columns === [] || $referencesColumns === []) {
787            throw new \InvalidArgumentException('A foreign key requires at least one column.');
788        }
789        if (count($columns) !== count($referencesColumns)) {
790            throw new \InvalidArgumentException(
791                'Foreign key columns and references must have matching arity; got '
792                . count($columns) . ' and ' . count($referencesColumns) . '.'
793            );
794        }
795        if ($initiallyDeferred && !$deferrable) {
796            throw new \InvalidArgumentException(
797                'A foreign key declares initiallyDeferred without deferrable: '
798                . 'INITIALLY DEFERRED implies DEFERRABLE.'
799            );
800        }
801
802        $clone = clone $this;
803        $clone->foreignKeys = [...$this->foreignKeys, [
804            'name' => ConstraintNamer::derive($this->table, $columns, 'foreign'),
805            'columns' => $columns,
806            'references' => [$referencesTable, ...$referencesColumns],
807            'onDelete' => $onDelete === null ? null : ($onDelete instanceof ForeignKeyAction ? $onDelete : ForeignKeyAction::fromChecked($onDelete)),
808            'onUpdate' => $onUpdate === null ? null : ($onUpdate instanceof ForeignKeyAction ? $onUpdate : ForeignKeyAction::fromChecked($onUpdate)),
809            'deferrable' => $deferrable,
810            'initiallyDeferred' => $initiallyDeferred,
811        ]];
812        return $clone;
813    }
814
815    /**
816     * Add a table-level CHECK constraint.
817     *
818     * @param  string  $expression
819     * @param  string|null  $name
820     * @return static
821     *
822     * @throws \InvalidArgumentException
823     */
824    public function check(string $expression, ?string $name = null): static
825    {
826        if (trim($expression) === '') {
827            throw new \InvalidArgumentException('A CHECK constraint requires a non-empty expression.');
828        }
829
830        $clone = clone $this;
831        $clone->checks = [...$this->checks, [
832            'name' => $name ?? $this->deriveCheckName($expression),
833            'expression' => $expression,
834        ]];
835        return $clone;
836    }
837
838    /**
839     * Derive the CHECK name from the expression's column references.
840     *
841     * @param  string  $expression
842     * @return string
843     */
844    private function deriveCheckName(string $expression): string
845    {
846        $declared = array_map(fn (array $column) => $column['name'], $this->columns);
847
848        // Longest-first so `user_id` matches before `id` inside it.
849        usort($declared, fn (string $a, string $b) => strlen($b) <=> strlen($a));
850
851        $covered = [];
852
853        foreach ($declared as $name) {
854            if (preg_match('/\b' . preg_quote($name, '/') . '\b/i', $expression) === 1) {
855                $covered[] = $name;
856            }
857        }
858
859        if ($covered === []) {
860            // No declared column referenced â€” positional suffix keeps the
861            // name unique per declaration order.
862            $covered = [(string) (count($this->checks) + 1)];
863        }
864
865        return ConstraintNamer::derive($this->table, $covered, 'check');
866    }
867
868    /**
869     * The CHECK constraints declared on this blueprint.
870     *
871     * @return list<array{name: string, expression: string}>
872     */
873    public function getChecks(): array
874    {
875        return $this->checks;
876    }
877
878    /**
879     * The foreign-key constraints declared on this blueprint â€” derived
880     * single-column plus explicit composite.
881     *
882     * @return list<array{name: string, columns: list<string>, references: list<string>, onDelete: ForeignKeyAction|null, onUpdate: ForeignKeyAction|null, deferrable: bool, initiallyDeferred: bool}>
883     */
884    public function getForeignKeys(): array
885    {
886        $foreignKeys = [];
887
888        foreach ($this->columns as $column) {
889            if ($column['foreign'] === null) {
890                continue;
891            }
892
893            [$table, $referenced] = explode('.', $column['foreign']);
894
895            $foreignKeys[] = [
896                'name' => ConstraintNamer::derive($this->table, [$column['name']], 'foreign'),
897                'columns' => [$column['name']],
898                'references' => [$table, $referenced],
899                'onDelete' => $column['onDelete'],
900                'onUpdate' => $column['onUpdate'],
901                // A flag-derived single-column FK has no deferrability
902                // options â€” those only exist on foreignKey().
903                'deferrable' => false,
904                'initiallyDeferred' => false,
905            ];
906        }
907
908        return [...$foreignKeys, ...$this->foreignKeys];
909    }
910
911    /**
912     * Drop a column (ALTER only).
913     *
914     * @param  string  $name
915     * @return static
916     */
917    public function dropColumn(string $name): static
918    {
919        $clone = clone $this;
920        $clone->dropColumns = [...$this->dropColumns, $name];
921        return $clone;
922    }
923
924    /**
925     * Provide the value existing rows are backfilled with for an added
926     * NOT NULL column that declares no default (ALTER only).
927     *
928     * @param  string  $name
929     * @param  mixed  $value  A scalar or an Expression.
930     * @return static
931     */
932    public function backfill(string $name, mixed $value): static
933    {
934        $clone = clone $this;
935        $clone->backfills = [...$this->backfills, $name => $value];
936        return $clone;
937    }
938
939    /**
940     * The explicit backfill values for added NOT NULL columns.
941     *
942     * @return array<string, mixed>
943     */
944    final public function getBackfills(): array
945    {
946        return $this->backfills;
947    }
948
949    /**
950     * Drop a foreign-key constraint by its live name (ALTER only).
951     *
952     * @param  string  $name
953     * @return static
954     */
955    public function dropForeignKey(string $name): static
956    {
957        $clone = clone $this;
958        $clone->dropForeignKeys = [...$this->dropForeignKeys, $name];
959        return $clone;
960    }
961
962    /**
963     * Drop a CHECK constraint by its live name (ALTER only).
964     *
965     * @param  string  $name
966     * @return static
967     */
968    public function dropCheck(string $name): static
969    {
970        $clone = clone $this;
971        $clone->dropChecks = [...$this->dropChecks, $name];
972        return $clone;
973    }
974
975    /**
976     * The live foreign-key constraint names to drop.
977     *
978     * @return list<string>
979     */
980    final public function getDropForeignKeys(): array
981    {
982        return $this->dropForeignKeys;
983    }
984
985    /**
986     * The live CHECK constraint names to drop.
987     *
988     * @return list<string>
989     */
990    final public function getDropChecks(): array
991    {
992        return $this->dropChecks;
993    }
994
995    /**
996     * The columns to create.
997     *
998     * @return list<ColumnShape>
999     */
1000    public function getColumns(): array
1001    {
1002        return $this->columns;
1003    }
1004
1005    /**
1006     * The columns to drop.
1007     *
1008     * @return list<string>
1009     */
1010    public function getDropColumns(): array
1011    {
1012        return $this->dropColumns;
1013    }
1014
1015    /**
1016     * The indexes declared on the blueprint.
1017     *
1018     * @return list<array{name: string, columns: list<string>, unique: bool, where: string|null, nullsNotDistinct: bool}>
1019     */
1020    public function getIndexes(): array
1021    {
1022        return $this->indexes;
1023    }
1024
1025    /**
1026     * Build the desired-state blueprint for a model from its metadata.
1027     *
1028     * @param  class-string<Model>  $model
1029     * @return static
1030     *
1031     * @throws \InvalidArgumentException
1032     */
1033    public static function fromMetadata(string $model): static
1034    {
1035        $metadata = MetadataFactory::for($model);
1036        $tableName = $metadata->tableName;
1037
1038        if ($tableName === null) {
1039            throw new \InvalidArgumentException(
1040                "Model [{$model}] owns no table (no columns of its own); there is "
1041                . 'nothing to build a blueprint for.'
1042            );
1043        }
1044
1045        $blueprint = new static($tableName);
1046
1047        // MTI children: the child table holds only the child's own columns
1048        // plus the derived key â€” the inherited columns live on the
1049        // parent's table.
1050        foreach ($metadata->properties as $mapping) {
1051            $column = $mapping->column;
1052
1053            if ($metadata->tableFor($mapping->columnName) !== $tableName) {
1054                continue; // inherited column â€” belongs on the parent's table
1055            }
1056
1057            $blueprint = $blueprint->column(
1058                $column->type,
1059                $mapping->columnName,
1060                primaryKey: $column->primaryKey,
1061                autoIncrement: $column->autoIncrement,
1062                nullable: $column->nullable,
1063                unique: $column->unique,
1064                index: $column->index,
1065                length: $column->length,
1066                precision: $column->precision,
1067                scale: $column->scale,
1068                values: $column->type === ColumnType::Enum ? $column->resolvedEnumValues() : null,
1069                default: $column->default,
1070                foreign: $column->foreign,
1071                onDelete: $column->onDelete,
1072                onUpdate: $column->onUpdate,
1073            );
1074
1075            // A model-declared backfill rides the column's ADD â€” inert on
1076            // a fresh create (backfills are consulted only for added
1077            // columns) and inert once the column exists live.
1078            if ($mapping->backfill !== null) {
1079                $blueprint = $blueprint->backfill($mapping->columnName, $mapping->backfill);
1080            }
1081        }
1082
1083        // Class-level composite constraints. For an MTI child, only
1084        // constraints over the child's own columns belong on the child's
1085        // table.
1086        $ownColumns = null;
1087
1088        if ($metadata->parentModel !== null) {
1089            $ownColumns = array_map(
1090                fn ($mapping) => $mapping->columnName,
1091                array_values(array_filter(
1092                    $metadata->properties,
1093                    fn ($mapping) => $metadata->tableFor($mapping->columnName) === $tableName,
1094                )),
1095            );
1096        }
1097
1098        foreach ($metadata->uniques as $unique) {
1099            if ($ownColumns !== null && array_diff($unique->columns, $ownColumns) !== []) {
1100                continue; // covers inherited columns â€” parent table's constraint
1101            }
1102
1103            // null name â†’ the blueprint derives the final
1104            // `{table}_{columns}_unique` name.
1105            $blueprint = $blueprint->index(
1106                $unique->name,
1107                $unique->columns,
1108                unique: true,
1109                where: $unique->where,
1110                nullsNotDistinct: $unique->nullsNotDistinct,
1111            );
1112        }
1113
1114        foreach ($metadata->indexes as $index) {
1115            if ($ownColumns !== null && array_diff($index->columns, $ownColumns) !== []) {
1116                continue;
1117            }
1118
1119            $blueprint = $blueprint->index($index->name, $index->columns, where: $index->where);
1120        }
1121
1122        foreach ($metadata->foreignKeys as $foreignKey) {
1123            if ($ownColumns !== null && array_diff($foreignKey->columns, $ownColumns) !== []) {
1124                continue;
1125            }
1126
1127            $blueprint = $blueprint->foreignKey(
1128                $foreignKey->columns,
1129                $foreignKey->resolvedReferences(),
1130                $foreignKey->resolvedReferencesColumns(),
1131                $foreignKey->onDelete,
1132                $foreignKey->onUpdate,
1133                $foreignKey->deferrable,
1134                $foreignKey->initiallyDeferred,
1135            );
1136        }
1137
1138        foreach ($metadata->checks as $check) {
1139            // A CHECK is table-level â€” it always belongs on the model's own
1140            // table, even for an MTI child (there are no column ownership
1141            // semantics to filter on).
1142            $blueprint = $blueprint->check($check->expression, $check->name);
1143        }
1144
1145        // Class-level #[Morphs] attributes need NO separate pass here: the
1146        // metadata factory injects the `{name}_type`/`{name}_id` synthetic
1147        // mappings into $metadata->properties, so the properties loop above
1148        // already emitted them as ordinary columns â€” with the exact shapes
1149        // morphs() produces (including the attribute's keyType).
1150
1151        // MTI children: the factory-emitted FK to the parent table. The
1152        // shared primary key IS the table link â€” the child declares no key
1153        // of its own, so the DDL carries `FOREIGN KEY (id) REFERENCES
1154        // <parent> (id) ON DELETE CASCADE`.
1155        if ($metadata->parentModel !== null) {
1156            $parentMetadata = MetadataFactory::for($metadata->parentModel);
1157            $parentTable = $parentMetadata->tableName;
1158            $parentKeys = $parentMetadata->primaryKeys;
1159
1160            if ($parentTable !== null && count($parentKeys) === 1 && $parentKeys[0]->name !== null) {
1161                $blueprint = $blueprint->foreignKey(
1162                    [$parentKeys[0]->name],
1163                    $parentTable,
1164                    [$parentKeys[0]->name],
1165                    ForeignKeyAction::Cascade,
1166                );
1167            }
1168        }
1169
1170        // Fail fast on duplicate index names WITHIN this blueprint â€” a
1171        // collision would compile two CREATE INDEX statements with the same
1172        // name and the second would fail at the database, far from the
1173        // declaration that caused it.
1174        $names = [];
1175
1176        foreach ($blueprint->getIndexes() as $index) {
1177            if (isset($names[$index['name']])) {
1178                throw new \InvalidArgumentException(sprintf(
1179                    'Model [%s] declares two indexes named [%s] (columns [%s]); '
1180                    . 'index names must be unique per table. Give the #[Index] '
1181                    . 'an explicit name, or drop the duplicate constraint.',
1182                    $model,
1183                    $index['name'],
1184                    implode(', ', $index['columns']),
1185                ));
1186            }
1187
1188            $names[$index['name']] = true;
1189        }
1190
1191        return $blueprint;
1192    }
1193}