Lines 96.31% 157 / 163
Functions and Methods 80.00% 24 / 30
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
SchemaGrammar 96.31% 157 / 163 80.00% 24 / 30 69 0.00% 0 / 1
 wrap n/a 0 / 0 n/a 0 / 0 0
 type n/a 0 / 0 n/a 0 / 0 0
 compileCreate 100.00% 1 / 1 100.00% 1 / 1 1
 compileTableBody 100.00% 22 / 22 100.00% 1 / 1 5
 compileCheckConstraint 75.00% 3 / 4 0.00% 0 / 1 2.06
 compileForeignKeyConstraint 91.66% 11 / 12 0.00% 0 / 1 6.02
 compileDeferrableClause 100.00% 3 / 3 100.00% 1 / 1 1
 compileAddColumns 93.75% 15 / 16 0.00% 0 / 1 5.01
 withTemporaryDefault 100.00% 3 / 3 100.00% 1 / 1 2
 compileSetColumnDefault 0.00% 0 / 1 0.00% 0 / 1 2
 compileDropColumnDefault 0.00% 0 / 1 0.00% 0 / 1 2
 compileDropColumns 100.00% 7 / 7 100.00% 1 / 1 2
 compileDrop 100.00% 1 / 1 100.00% 1 / 1 1
 compileRenameTable 100.00% 1 / 1 100.00% 1 / 1 1
 compileRenameColumn 100.00% 2 / 2 100.00% 1 / 1 1
 compileCopyTable 100.00% 4 / 4 100.00% 1 / 1 2
 compileModifyColumn 100.00% 3 / 3 100.00% 1 / 1 1
 compileAddForeignKey 100.00% 3 / 3 100.00% 1 / 1 1
 compileDropForeignKey 100.00% 3 / 3 100.00% 1 / 1 1
 compileAddCheck 100.00% 3 / 3 100.00% 1 / 1 1
 compileDropCheck 100.00% 3 / 3 100.00% 1 / 1 1
 compileDropIndex n/a 0 / 0 n/a 0 / 0 0
 compileIndexes 100.00% 18 / 18 100.00% 1 / 1 4
 compileNullsNotDistinctClause 100.00% 5 / 5 100.00% 1 / 1 2
 compilePartialIndexClause 100.00% 3 / 3 100.00% 1 / 1 1
 assertValidIdentifier n/a 0 / 0 n/a 0 / 0 0
 compileAddColumn 100.00% 1 / 1 100.00% 1 / 1 1
 compileDropColumn 0.00% 0 / 1 0.00% 0 / 1 2
 compileColumnDefinition 100.00% 16 / 16 100.00% 1 / 1 11
 compileEnumCheckClause 100.00% 8 / 8 100.00% 1 / 1 4
 compileDefault 100.00% 7 / 7 100.00% 1 / 1 4
 autoIncrement n/a 0 / 0 n/a 0 / 0 0
 autoIncrementBeforePrimaryKey 100.00% 1 / 1 100.00% 1 / 1 1
 requireLength 100.00% 3 / 3 100.00% 1 / 1 2
 suffix 100.00% 7 / 7 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Schema\Grammars;
6
7use BlueprintAU\Radiant\Database\Concerns\ConcatenatesStatements;
8use BlueprintAU\Radiant\Database\Concerns\QuotesLiterals;
9use BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException;
10use BlueprintAU\Radiant\Database\Schema\Blueprint;
11use BlueprintAU\Radiant\Database\Schema\Enums\ColumnType;
12use BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction;
13
14/**
15 * Compiles schema definitions into dialect DDL.
16 *
17 * @phpstan-import-type ColumnShape from \BlueprintAU\Radiant\Database\Schema\Blueprint
18 */
19abstract class SchemaGrammar
20{
21    use QuotesLiterals;
22    use ConcatenatesStatements;
23
24    /**
25     * Wrap an identifier in the dialect's quote character.
26     *
27     * @param  string  $value
28     * @return string
29     */
30    abstract protected function wrap(string $value): string;
31
32    /**
33     * Map a logical column type to the dialect's native type.
34     *
35     * @param  ColumnType  $type
36     * @param  int|null  $length
37     * @param  int|null  $precision  Fractional-seconds digits (1–6) for datetime types.
38     * @param  int|null  $scale  Fractional digits for a decimal column.
39     * @return string
40     */
41    abstract public function type(ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): string;
42
43    /**
44     * Compile a `CREATE TABLE` statement.
45     *
46     * @param  Blueprint  $blueprint
47     * @return string
48     */
49    final public function compileCreate(Blueprint $blueprint): string
50    {
51        return 'CREATE TABLE ' . $this->wrap($blueprint->getTable()) . ' (' . $this->compileTableBody($blueprint) . ')';
52    }
53
54    /**
55     * Compile the parenthesized body of a `CREATE TABLE`.
56     *
57     * @param  Blueprint  $blueprint
58     * @return string
59     */
60    final public function compileTableBody(Blueprint $blueprint): string
61    {
62        $columns = $blueprint->getColumns();
63        if ($columns === []) {
64            throw new \InvalidArgumentException('Cannot create a table with no columns.');
65        }
66
67        // A composite primary key is declared table-level, not per-column.
68        $primaryKeys = array_values(array_filter(
69            $columns,
70            fn (array $column) => $column['primaryKey'] === true,
71        ));
72        $composite = count($primaryKeys) > 1;
73
74        $definitions = array_map(
75            fn (array $column) => $this->compileColumnDefinition($column, $composite),
76            $columns,
77        );
78
79        if ($composite) {
80            $definitions[] = 'PRIMARY KEY (' . implode(', ', array_map(
81                fn (array $column) => $this->wrap($column['name']),
82                $primaryKeys,
83            )) . ')';
84        }
85
86        foreach ($blueprint->getForeignKeys() as $foreignKey) {
87            $definitions[] = $this->compileForeignKeyConstraint($foreignKey);
88        }
89
90        foreach ($blueprint->getChecks() as $check) {
91            $definitions[] = $this->compileCheckConstraint($check);
92        }
93
94        return implode(', ', $definitions);
95    }
96
97    /**
98     * Compile a table-level CHECK constraint.
99     *
100     * @param  array{name: string|null, expression: string}  $check
101     * @return string
102     */
103    protected function compileCheckConstraint(array $check): string
104    {
105        if ($check['name'] !== null) {
106            $this->assertValidIdentifier($check['name']);
107            return 'CONSTRAINT ' . $this->wrap($check['name']) . ' CHECK (' . $check['expression'] . ')';
108        }
109
110        return 'CHECK (' . $check['expression'] . ')';
111    }
112
113    /**
114     * Compile a table-level foreign-key constraint.
115     *
116     * @param  array{columns: list<string>, references: list<string>, onDelete: ForeignKeyAction|null, onUpdate: ForeignKeyAction|null, deferrable: bool, initiallyDeferred: bool}  $foreignKey
117     * @return string
118     */
119    protected function compileForeignKeyConstraint(array $foreignKey): string
120    {
121        $table = array_shift($foreignKey['references']);
122        if ($table === null) {
123            throw new \InvalidArgumentException('A foreign key constraint requires a referenced table.');
124        }
125
126        // Statement assembly: the constraint body plus optional clauses —
127        // the concatenate() join (absent clause = '' segment, dropped),
128        // NOT a list join. Column lists inside the parens stay list-joins.
129        return $this->concatenate([
130            'FOREIGN KEY (' . implode(', ', array_map(fn (string $column) => $this->wrap($column), $foreignKey['columns'])) . ')',
131            'REFERENCES ' . $this->wrap($table) . ' (' . implode(', ', array_map(fn (string $column) => $this->wrap($column), $foreignKey['references'])) . ')',
132            $foreignKey['onDelete'] === null ? '' : 'ON DELETE ' . $foreignKey['onDelete']->value,
133            $foreignKey['onUpdate'] === null ? '' : 'ON UPDATE ' . $foreignKey['onUpdate']->value,
134            $foreignKey['initiallyDeferred'] || $foreignKey['deferrable']
135                ? $this->compileDeferrableClause($foreignKey['initiallyDeferred'])
136                : '',
137        ]);
138    }
139
140    /**
141     * The dialect's `DEFERRABLE` clause for a foreign key.
142     *
143     * @param  bool  $initiallyDeferred
144     * @return string
145     * @throws UnsupportedFeatureException
146     */
147    protected function compileDeferrableClause(bool $initiallyDeferred): string
148    {
149        throw new UnsupportedFeatureException(
150            'This dialect does not support DEFERRABLE foreign keys.'
151        );
152    }
153
154    /**
155     * Compile the `ALTER TABLE ... ADD COLUMN` statement(s).
156     *
157     * The base joins every column into one statement; SQLite overrides to
158     * emit one statement per column.
159     *
160     * @param  Blueprint  $blueprint
161     * @return list<string>
162     * @throws \InvalidArgumentException
163     */
164    public function compileAddColumns(Blueprint $blueprint): array
165    {
166        $columns = $blueprint->getColumns();
167        if ($columns === []) {
168            throw new \InvalidArgumentException('Cannot add columns with no columns defined.');
169        }
170
171        $backfills = $blueprint->getBackfills();
172        $table = $blueprint->getTable();
173
174        // A NOT NULL column with a backfill is added with the backfill as
175        // a TEMPORARY default (existing rows get it), then the default is
176        // restored to the declared one (new rows get that) or dropped.
177        $statements = ['ALTER TABLE ' . $this->wrap($table) . ' ADD COLUMN ' . implode(', ADD COLUMN ', array_map(
178            fn (array $column) => $this->compileAddColumn($this->withTemporaryDefault($column, $backfills)),
179            $columns,
180        ))];
181
182        foreach ($columns as $column) {
183            if (!array_key_exists($column['name'], $backfills)) {
184                continue;
185            }
186
187            $statements[] = $column['default'] !== null
188                ? $this->compileSetColumnDefault($table, $column['name'], $column['default'])
189                : $this->compileDropColumnDefault($table, $column['name']);
190        }
191
192        return $statements;
193    }
194
195    /**
196     * A column definition with its default swapped for the temporary
197     * backfill value, when one is declared.
198     *
199     * @param  ColumnShape  $column
200     * @param  array<string, mixed>  $backfills
201     * @return ColumnShape
202     */
203    protected function withTemporaryDefault(array $column, array $backfills): array
204    {
205        if (!array_key_exists($column['name'], $backfills)) {
206            return $column;
207        }
208
209        return [...$column, 'default' => $backfills[$column['name']]];
210    }
211
212    /**
213     * Compile the statement that restores a column's declared default
214     * after a backfill.
215     *
216     * @param  string  $table
217     * @param  string  $column
218     * @param  mixed  $default  A scalar or an Expression.
219     * @return string
220     * @throws UnsupportedFeatureException
221     */
222    protected function compileSetColumnDefault(string $table, string $column, mixed $default): string
223    {
224        throw new UnsupportedFeatureException('This dialect does not support altering a column default.');
225    }
226
227    /**
228     * Compile the statement that drops a column's temporary default after
229     * a backfill.
230     *
231     * @param  string  $table
232     * @param  string  $column
233     * @return string
234     * @throws UnsupportedFeatureException
235     */
236    protected function compileDropColumnDefault(string $table, string $column): string
237    {
238        throw new UnsupportedFeatureException('This dialect does not support altering a column default.');
239    }
240
241    /**
242     * Compile the `ALTER TABLE ... DROP COLUMN` statement(s).
243     *
244     * The base joins every column into one statement; SQLite overrides to
245     * emit one statement per column.
246     *
247     * @param  Blueprint  $blueprint
248     * @return list<string>
249     * @throws \InvalidArgumentException
250     * @throws UnsupportedFeatureException
251     */
252    public function compileDropColumns(Blueprint $blueprint): array
253    {
254        $columns = $blueprint->getDropColumns();
255        if ($columns === []) {
256            throw new \InvalidArgumentException('Cannot drop columns with no columns defined.');
257        }
258
259        return ['ALTER TABLE ' . $this->wrap($blueprint->getTable()) . ' DROP COLUMN ' . implode(', DROP COLUMN ', array_map(
260            fn (string $column) => $this->compileDropColumn($column),
261            $columns,
262        ))];
263    }
264
265    /**
266     * Compile a `DROP TABLE` statement.
267     *
268     * @param  string  $table
269     * @return string
270     */
271    final public function compileDrop(string $table): string
272    {
273        return 'DROP TABLE ' . $this->wrap($table);
274    }
275
276    /**
277     * Compile an `ALTER TABLE ... RENAME TO` statement.
278     *
279     * @param  string  $from
280     * @param  string  $to
281     * @return string
282     */
283    final public function compileRenameTable(string $from, string $to): string
284    {
285        return 'ALTER TABLE ' . $this->wrap($from) . ' RENAME TO ' . $this->wrap($to);
286    }
287
288    /**
289     * Compile an `ALTER TABLE ... RENAME COLUMN` statement.
290     *
291     * @param  string  $table
292     * @param  string  $from
293     * @param  string  $to
294     * @return string
295     */
296    final public function compileRenameColumn(string $table, string $from, string $to): string
297    {
298        return 'ALTER TABLE ' . $this->wrap($table) . ' RENAME COLUMN '
299            . $this->wrap($from) . ' TO ' . $this->wrap($to);
300    }
301
302    /**
303     * Compile an `INSERT INTO ... SELECT` data-copy statement.
304     *
305     * @param  string  $from
306     * @param  string  $to
307     * @param  list<string>  $columns
308     * @return string
309     * @throws \InvalidArgumentException
310     */
311    final public function compileCopyTable(string $from, string $to, array $columns): string
312    {
313        if ($columns === []) {
314            throw new \InvalidArgumentException('A table copy requires at least one column.');
315        }
316
317        $wrapped = implode(', ', array_map(fn (string $column) => $this->wrap($column), $columns));
318
319        return 'INSERT INTO ' . $this->wrap($to) . ' (' . $wrapped . ') SELECT ' . $wrapped . ' FROM ' . $this->wrap($from);
320    }
321
322    /**
323     * Compile an `ALTER TABLE ... MODIFY/ALTER COLUMN` statement — the
324     * in-place content-drift form.
325     *
326     * @param  Blueprint  $blueprint
327     * @return list<string>
328     * @throws UnsupportedFeatureException
329     */
330    public function compileModifyColumn(Blueprint $blueprint): array
331    {
332        throw new UnsupportedFeatureException(
333            'This dialect does not support modifying columns in place; the change requires a table rebuild.'
334        );
335    }
336
337    /**
338     * Compile an `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`
339     * statement — the in-place FK-add form.
340     *
341     * @param  string  $table
342     * @param  array{columns: list<string>, references: list<string>, onDelete: ForeignKeyAction|null, onUpdate: ForeignKeyAction|null, deferrable: bool, initiallyDeferred: bool}  $foreignKey
343     * @param  string  $name
344     * @return string
345     * @throws UnsupportedFeatureException
346     */
347    public function compileAddForeignKey(string $table, array $foreignKey, string $name): string
348    {
349        throw new UnsupportedFeatureException(
350            'This dialect does not support adding a foreign key constraint in place; the change requires a table rebuild.'
351        );
352    }
353
354    /**
355     * Compile the `ALTER TABLE ... DROP FOREIGN KEY/CONSTRAINT` statement
356     * — the in-place FK-drop form.
357     *
358     * @param  string  $table
359     * @param  string  $name
360     * @return string
361     * @throws UnsupportedFeatureException
362     */
363    public function compileDropForeignKey(string $table, string $name): string
364    {
365        throw new UnsupportedFeatureException(
366            'This dialect does not support dropping a foreign key constraint in place; the change requires a table rebuild.'
367        );
368    }
369
370    /**
371     * Compile an `ALTER TABLE ... ADD CONSTRAINT ... CHECK` statement —
372     * the in-place CHECK-add form.
373     *
374     * @param  string  $table
375     * @param  string  $name
376     * @param  string  $expression
377     * @return string
378     * @throws UnsupportedFeatureException
379     */
380    public function compileAddCheck(string $table, string $name, string $expression): string
381    {
382        throw new UnsupportedFeatureException(
383            'This dialect does not support adding a CHECK constraint in place; the change requires a table rebuild.'
384        );
385    }
386
387    /**
388     * Compile an `ALTER TABLE ... DROP CONSTRAINT` statement — the
389     * in-place CHECK-drop form.
390     *
391     * @param  string  $table
392     * @param  string  $name
393     * @return string
394     * @throws UnsupportedFeatureException
395     */
396    public function compileDropCheck(string $table, string $name): string
397    {
398        throw new UnsupportedFeatureException(
399            'This dialect does not support dropping a CHECK constraint in place; the change requires a table rebuild.'
400        );
401    }
402
403    /**
404     * Compile a `DROP INDEX` statement for an existing index name.
405     *
406     * @param  string  $name
407     * @param  string  $table
408     * @return string
409     */
410    abstract public function compileDropIndex(string $name, string $table): string;
411
412    /**
413     * Compile the `CREATE INDEX` statements for the blueprint's indexes.
414     *
415     * @param  Blueprint  $blueprint
416     * @return list<string>
417     */
418    final public function compileIndexes(Blueprint $blueprint): array
419    {
420        $table = $blueprint->getTable();
421
422        return array_map(
423            function (array $index) use ($table): string {
424                // Names on the blueprint are FINAL (built at declaration
425                // time — user-set names verbatim, derived names with the
426                // table prefix and kind suffix). The grammar renders them
427                // as-is; its only job is quoting + dialect validation.
428                $this->assertValidIdentifier($index['name']);
429
430                $sql = ($index['unique'] ? 'CREATE UNIQUE INDEX ' : 'CREATE INDEX ')
431                    . $this->wrap($index['name'])
432                    . ' ON ' . $this->wrap($table)
433                    . ' (' . implode(', ', array_map(fn (string $column) => $this->wrap($column), $index['columns'])) . ')';
434
435                // The option clauses are optional segments of the one
436                // statement — statement assembly, not a list join. The
437                // NULLS clause rides EVERY unique index (the base carries
438                // the default semantics implicitly as ''; Postgres pins
439                // them explicitly); a plain index renders neither.
440                return $this->concatenate([
441                    $sql,
442                    $index['unique']
443                        ? $this->compileNullsNotDistinctClause($index['nullsNotDistinct'])
444                        : '',
445                    $index['where'] === null ? '' : $this->compilePartialIndexClause($index['where']),
446                ]);
447            },
448            $blueprint->getIndexes(),
449        );
450    }
451
452    /**
453     * The dialect's `NULLS [NOT] DISTINCT` clause for a UNIQUE index.
454     *
455     * @param  bool  $nullsNotDistinct
456     * @return string
457     * @throws UnsupportedFeatureException
458     */
459    protected function compileNullsNotDistinctClause(bool $nullsNotDistinct): string
460    {
461        if ($nullsNotDistinct) {
462            throw new UnsupportedFeatureException(
463                'This dialect does not support NULLS NOT DISTINCT on a unique index.'
464            );
465        }
466
467        return '';
468    }
469
470    /**
471     * The dialect's partial (filtered) index clause for the given
472     * predicate.
473     *
474     * @param  string  $predicate
475     * @return string
476     * @throws UnsupportedFeatureException
477     */
478    protected function compilePartialIndexClause(string $predicate): string
479    {
480        throw new UnsupportedFeatureException(
481            'This dialect does not support partial (filtered) indexes.'
482        );
483    }
484
485    /**
486     * Assert an identifier is valid for this dialect.
487     *
488     * @param  string  $name
489     * @return void
490     * @throws \InvalidArgumentException
491     */
492    abstract public function assertValidIdentifier(string $name): void;
493
494    /**
495     * Compile one column's `ADD COLUMN` definition.
496     *
497     * @param  ColumnShape  $column
498     * @return string
499     */
500    protected function compileAddColumn(array $column): string
501    {
502        return $this->compileColumnDefinition($column);
503    }
504
505    /**
506     * Compile one column's `DROP COLUMN` clause.
507     *
508     * @param  string  $column
509     * @return string
510     * @throws UnsupportedFeatureException
511     */
512    protected function compileDropColumn(string $column): string
513    {
514        throw new UnsupportedFeatureException('This dialect does not support dropping columns.');
515    }
516
517    /**
518     * Compile a single column definition.
519     *
520     * @param  ColumnShape  $column
521     * @param  bool  $composite  Whether the column is part of a composite primary key.
522     * @return string
523     */
524    protected function compileColumnDefinition(array $column, bool $composite = false): string
525    {
526        $name = $this->wrap($column['name']);
527        $type = $this->type($column['type'], $column['length'], $column['precision'], $column['scale']);
528
529        // A composite PK has no generated id in the ORM's contract (the
530        // caller assigns every key part — insertGetId is a single-column
531        // concept), so the auto-increment clause is suppressed on its
532        // member columns. On SQLite it would be outright invalid:
533        // AUTOINCREMENT is only legal on a single-column INTEGER PRIMARY
534        // KEY. Auto-increment placement is otherwise dialect-dependent:
535        // MySQL/Postgres render it before PRIMARY KEY (`AUTO_INCREMENT
536        // PRIMARY KEY`, `GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY`),
537        // while SQLite requires it after (`INTEGER PRIMARY KEY
538        // AUTOINCREMENT`).
539        $autoIncrement = $column['autoIncrement'] === true && !$composite;
540        $beforeKey = $autoIncrement && $this->autoIncrementBeforePrimaryKey();
541        $afterKey = $autoIncrement && !$this->autoIncrementBeforePrimaryKey();
542
543        // Statement assembly: the optional clauses are concatenate()
544        // segments — NOT NULL / DEFAULT / UNIQUE / the auto-increment and
545        // PRIMARY KEY clauses each may be absent. No list items here.
546        return $this->concatenate([
547            $name,
548            $type,
549            $column['nullable'] !== true ? 'NOT NULL' : '',
550            $column['default'] !== null ? 'DEFAULT ' . $this->compileDefault($column['default']) : '',
551            $column['unique'] === true ? 'UNIQUE' : '',
552            $beforeKey ? $this->autoIncrement() : '',
553            // A single-column PK is declared inline; a composite PK is
554            // declared table-level (see compileCreate), so a column that is
555            // part of a composite PK must not also get an inline PRIMARY KEY.
556            $column['primaryKey'] === true && !$composite ? 'PRIMARY KEY' : '',
557            $afterKey ? $this->autoIncrement() : '',
558            $this->compileEnumCheckClause($column),
559        ]);
560    }
561
562    /**
563     * The inline CHECK clause for an enum column — the portable enum
564     * rendering (a sized string constrained to its declared values).
565     *
566     * @param  ColumnShape  $column
567     * @return string
568     */
569    protected function compileEnumCheckClause(array $column): string
570    {
571        $values = $column['values'] ?? null;
572
573        if ($column['type'] !== ColumnType::Enum || $values === null || $values === []) {
574            return '';
575        }
576
577        $literals = implode(', ', array_map(
578            fn (string $value) => $this->quoteLiteral($value),
579            $values,
580        ));
581
582        return 'CHECK (' . $this->wrap($column['name']) . ' IN (' . $literals . '))';
583    }
584
585    /**
586     * Compile a column default.
587     *
588     * @param  mixed  $default  A scalar or an Expression.
589     * @return string
590     * @throws \InvalidArgumentException
591     */
592    protected function compileDefault(mixed $default): string
593    {
594        if ($default instanceof \BlueprintAU\Radiant\Database\Query\Expression) {
595            return $default->value;
596        }
597        if (is_scalar($default) || $default === null) {
598            return $this->quoteLiteral($default);
599        }
600        throw new \InvalidArgumentException(
601            'A column default must be a scalar or an Expression; got ' . get_debug_type($default) . '.'
602        );
603    }
604
605    /**
606     * The dialect's auto-increment clause.
607     *
608     * @return string
609     */
610    abstract protected function autoIncrement(): string;
611
612    /**
613     * Whether the auto-increment clause renders before `PRIMARY KEY`.
614     *
615     * @return bool
616     */
617    protected function autoIncrementBeforePrimaryKey(): bool
618    {
619        return true;
620    }
621
622    /**
623     * Require a length for a string column.
624     *
625     * @param  int|null  $length
626     * @return int
627     * @throws \InvalidArgumentException
628     */
629    protected function requireLength(?int $length): int
630    {
631        if ($length === null) {
632            throw new \InvalidArgumentException('A string column requires a length.');
633        }
634        return $length;
635    }
636
637    /**
638     * The parenthesized suffix for a sized type — `(a,b)` when any part is
639     * non-null, an empty string when all are null.
640     *
641     * @param  \Stringable|int|null  ...$parts  The size parts (length, precision, scale).
642     * @return string
643     */
644    protected function suffix(\Stringable|int|null ...$parts): string
645    {
646        // Drop the nulls, then stringify what's left. The filter is
647        // explicit (`!== null`), not the default falsy filter: a numeric
648        // part of 0 stringifies to '0', which is falsy and would be
649        // silently dropped.
650        $rendered = array_map(
651            fn (\Stringable|int $part): string => (string) $part,
652            array_filter($parts, fn (\Stringable|int|null $part): bool => $part !== null),
653        );
654
655        if ($rendered === []) {
656            return '';
657        }
658
659        return '(' . implode(',', $rendered) . ')';
660    }
661}