Lines 94.91% 56 / 59
Functions and Methods 87.50% 14 / 16
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
PostgresSchemaGrammar 94.91% 56 / 59 87.50% 14 / 16 38.19 0.00% 0 / 1
 wrap 100.00% 1 / 1 100.00% 1 / 1 1
 type 100.00% 15 / 15 100.00% 1 / 1 16
 autoIncrement 100.00% 1 / 1 100.00% 1 / 1 1
 compileNullsNotDistinctClause 100.00% 1 / 1 100.00% 1 / 1 2
 compilePartialIndexClause 100.00% 1 / 1 100.00% 1 / 1 1
 compileDeferrableClause 100.00% 1 / 1 100.00% 1 / 1 2
 compileDropIndex 100.00% 3 / 3 100.00% 1 / 1 1
 compileDropColumn 100.00% 1 / 1 100.00% 1 / 1 1
 compileSetColumnDefault 0.00% 0 / 2 0.00% 0 / 1 2
 compileDropColumnDefault 0.00% 0 / 1 0.00% 0 / 1 2
 compileModifyColumn 100.00% 15 / 15 100.00% 1 / 1 5
 compileAddForeignKey 100.00% 3 / 3 100.00% 1 / 1 1
 compileDropForeignKey 100.00% 2 / 2 100.00% 1 / 1 1
 compileAddCheck 100.00% 3 / 3 100.00% 1 / 1 1
 compileDropCheck 100.00% 2 / 2 100.00% 1 / 1 1
 assertValidIdentifier 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\Schema\Blueprint;
8use BlueprintAU\Radiant\Database\Schema\Enums\ColumnType;
9
10/**
11 * The Postgres dialect of the schema grammar.
12 *
13 * Identifiers are quoted with double quotes (embedded quotes doubled).
14 * Auto-increment renders as `GENERATED BY DEFAULT AS IDENTITY` (the modern
15 * SQL-standard identity column, preferred over `SERIAL`). Postgres supports
16 * dropping columns natively.
17 */
18final class PostgresSchemaGrammar extends SchemaGrammar
19{
20    /**
21     * Wrap an identifier in Postgres double quotes.
22     *
23     * @param  string  $value
24     * @return string
25     */
26    protected function wrap(string $value): string
27    {
28        return '"' . str_replace('"', '""', $value) . '"';
29    }
30
31    /**
32     * Map a logical column type to Postgres' native type.
33     *
34     * Timestamp types render declared precision (`timestamp(3)`) — a display
35     * rounding hint; Postgres always stores microseconds natively.
36     *
37     * @param  ColumnType  $type
38     * @param  int|null  $length
39     * @param  int|null  $precision
40     * @param  int|null  $scale
41     * @return string
42     */
43    public function type(ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): string
44    {
45        return match ($type) {
46            ColumnType::String => 'varchar' . $this->suffix($this->requireLength($length)),
47            ColumnType::Char => 'char' . $this->suffix($this->requireLength($length)),
48            ColumnType::Text => 'text',
49            ColumnType::BigInt => 'bigint',
50            ColumnType::Int => 'integer',
51            ColumnType::Decimal => 'numeric' . $this->suffix($precision, $scale),
52            ColumnType::Float => 'double precision',
53            ColumnType::Boolean => 'boolean',
54            ColumnType::Date => 'date',
55            ColumnType::DateTime => 'timestamp' . $this->suffix($precision),
56            ColumnType::Timestamp => 'timestamp' . $this->suffix($precision),
57            ColumnType::Json => 'jsonb',
58            ColumnType::Enum => 'varchar' . $this->suffix($this->requireLength($length)),
59            ColumnType::Binary => 'bytea',
60            ColumnType::Uuid => 'uuid',
61        };
62    }
63
64    /**
65     * The Postgres auto-increment clause.
66     *
67     * @return string
68     */
69    protected function autoIncrement(): string
70    {
71        return 'GENERATED BY DEFAULT AS IDENTITY';
72    }
73
74    /**
75     * Postgres 15+ renders the NULLS clause explicitly in both directions.
76     *
77     * @param  bool  $nullsNotDistinct
78     * @return string
79     */
80    protected function compileNullsNotDistinctClause(bool $nullsNotDistinct): string
81    {
82        return $nullsNotDistinct ? 'NULLS NOT DISTINCT' : 'NULLS DISTINCT';
83    }
84
85    /**
86     * Postgres renders partial (filtered) indexes — `CREATE INDEX ... WHERE
87     * predicate`.
88     *
89     * @param  string  $predicate
90     * @return string
91     */
92    protected function compilePartialIndexClause(string $predicate): string
93    {
94        return 'WHERE ' . $predicate;
95    }
96
97    /**
98     * Postgres renders `DEFERRABLE [INITIALLY DEFERRED]` on foreign keys.
99     *
100     * @param  bool  $initiallyDeferred
101     * @return string
102     */
103    protected function compileDeferrableClause(bool $initiallyDeferred): string
104    {
105        return $initiallyDeferred ? 'DEFERRABLE INITIALLY DEFERRED' : 'DEFERRABLE';
106    }
107
108    /**
109     * Postgres drops an index by name alone: `DROP INDEX name`.
110     *
111     * @param  string  $name
112     * @param  string  $table
113     * @return string
114     */
115    public function compileDropIndex(string $name, string $table): string
116    {
117        unset($table);
118        $this->assertValidIdentifier($name);
119
120        return 'DROP INDEX ' . $this->wrap($name);
121    }
122
123    /**
124     * Compile one column's `DROP COLUMN` clause.
125     *
126     * @param  string  $column
127     * @return string
128     */
129    protected function compileDropColumn(string $column): string
130    {
131        return $this->wrap($column);
132    }
133
134    /**
135     * Compile the statement that restores a column's declared default.
136     *
137     * @param  string  $table
138     * @param  string  $column
139     * @param  mixed  $default  A scalar or an Expression.
140     * @return string
141     */
142    #[\Override]
143    protected function compileSetColumnDefault(string $table, string $column, mixed $default): string
144    {
145        return 'ALTER TABLE ' . $this->wrap($table) . ' ALTER COLUMN ' . $this->wrap($column)
146            . ' SET DEFAULT ' . $this->compileDefault($default);
147    }
148
149    /**
150     * Compile the statement that drops a column's temporary default.
151     *
152     * @param  string  $table
153     * @param  string  $column
154     * @return string
155     */
156    #[\Override]
157    protected function compileDropColumnDefault(string $table, string $column): string
158    {
159        return 'ALTER TABLE ' . $this->wrap($table) . ' ALTER COLUMN ' . $this->wrap($column) . ' DROP DEFAULT';
160    }
161
162    /**
163     * Compile the `ALTER TABLE ... ALTER COLUMN` statements — Postgres'
164     * in-place content-drift form.
165     *
166     * @param  Blueprint  $blueprint
167     * @return list<string>
168     */
169    public function compileModifyColumn(Blueprint $blueprint): array
170    {
171        $table = $blueprint->getTable();
172        $columns = $blueprint->getColumns();
173        if ($columns === []) {
174            throw new \InvalidArgumentException('Cannot modify columns with no columns defined.');
175        }
176
177        $statements = [];
178
179        foreach ($columns as $column) {
180            $name = $this->wrap($column['name']);
181            $base = 'ALTER TABLE ' . $this->wrap($table) . ' ALTER COLUMN ' . $name;
182
183            // TYPE first: the type text is the identity of the change, and
184            // a type change must land before constraints that depend on it.
185            // The USING clause casts the existing values explicitly —
186            // Postgres only auto-casts implicitly-castable pairs, so a
187            // text→numeric / text→timestamp reshape needs the explicit cast.
188            // The full shape (length, precision, scale) renders so a
189            // Decimal(10,2) stays numeric(10,2) — otherwise the second
190            // diff sees a perpetual drift.
191            $type = $this->type($column['type'], $column['length'], $column['precision'], $column['scale']);
192            $statements[] = $base . ' TYPE ' . $type . ' USING ' . $name . '::' . $type;
193
194            // Nullability second: SET NOT NULL validates existing rows, so
195            // it must come after the type conversion.
196            $statements[] = $column['nullable'] ? $base . ' DROP NOT NULL' : $base . ' SET NOT NULL';
197
198            // Default last: a null desired default means DROP DEFAULT (the
199            // column carries no default); anything else renders the literal.
200            $statements[] = $column['default'] === null
201                ? $base . ' DROP DEFAULT'
202                : $base . ' SET DEFAULT ' . $this->compileDefault($column['default']);
203        }
204
205        return $statements;
206    }
207
208    /**
209     * Compile an `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`
210     * statement — Postgres' in-place FK-add form.
211     *
212     * @param  string  $table
213     * @param  array{columns: list<string>, references: list<string>, onDelete: \BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction|null, onUpdate: \BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction|null, deferrable: bool, initiallyDeferred: bool}  $foreignKey
214     * @param  string  $name
215     * @return string
216     */
217    public function compileAddForeignKey(string $table, array $foreignKey, string $name): string
218    {
219        $this->assertValidIdentifier($name);
220
221        return 'ALTER TABLE ' . $this->wrap($table) . ' ADD CONSTRAINT ' . $this->wrap($name) . ' '
222            . $this->compileForeignKeyConstraint($foreignKey);
223    }
224
225    /**
226     * Compile an `ALTER TABLE ... DROP CONSTRAINT` statement — Postgres'
227     * in-place constraint-drop form (covers FKs and CHECKs).
228     *
229     * @param  string  $table
230     * @param  string  $name
231     * @return string
232     */
233    public function compileDropForeignKey(string $table, string $name): string
234    {
235        $this->assertValidIdentifier($name);
236
237        return 'ALTER TABLE ' . $this->wrap($table) . ' DROP CONSTRAINT ' . $this->wrap($name);
238    }
239
240    /**
241     * Compile an `ALTER TABLE ... ADD CONSTRAINT ... CHECK` statement —
242     * Postgres' in-place CHECK-add form.
243     *
244     * @param  string  $table
245     * @param  string  $name
246     * @param  string  $expression
247     * @return string
248     */
249    public function compileAddCheck(string $table, string $name, string $expression): string
250    {
251        $this->assertValidIdentifier($name);
252
253        return 'ALTER TABLE ' . $this->wrap($table) . ' ADD CONSTRAINT ' . $this->wrap($name)
254            . ' CHECK (' . $expression . ')';
255    }
256
257    /**
258     * Compile an `ALTER TABLE ... DROP CONSTRAINT` statement for a CHECK.
259     *
260     * @param  string  $table
261     * @param  string  $name
262     * @return string
263     */
264    public function compileDropCheck(string $table, string $name): string
265    {
266        $this->assertValidIdentifier($name);
267
268        return 'ALTER TABLE ' . $this->wrap($table) . ' DROP CONSTRAINT ' . $this->wrap($name);
269    }
270
271    /**
272     * Postgres caps identifiers at 63 bytes — fail fast at compile time,
273     * never silently truncate.
274     *
275     * @param  string  $name
276     * @return void
277     * @throws \InvalidArgumentException
278     */
279    public function assertValidIdentifier(string $name): void
280    {
281        if (strlen($name) > 63) {
282            throw new \InvalidArgumentException(sprintf(
283                'Identifier [%s] exceeds Postgres\'s 63-byte limit (%d bytes); '
284                . 'declare a shorter #[Unique(name: ...)] / #[Index(name: ...)].',
285                $name,
286                strlen($name),
287            ));
288        }
289    }
290}