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 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace BlueprintAU\Radiant\Database\Schema; | |
| 6 | ||
| 7 | use BlueprintAU\Radiant\Attributes\Column; | |
| 8 | use BlueprintAU\Radiant\Attributes\ReferenceResolver; | |
| 9 | use BlueprintAU\Radiant\Database\Schema\Enums\ColumnType; | |
| 10 | use BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction; | |
| 11 | use BlueprintAU\Radiant\Model; | |
| 12 | use 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 | */ | |
| 39 | final 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 | } |