Lines 100.00% 47 / 47
Functions and Methods 100.00% 12 / 12
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
MySqlSchemaGrammar 100.00% 47 / 47 100.00% 12 / 12 30 100.00% 1 / 1
 wrap 100.00% 1 / 1 100.00% 1 / 1 1
 type 100.00% 15 / 15 100.00% 1 / 1 17
 autoIncrement 100.00% 1 / 1 100.00% 1 / 1 1
 compileDropIndex 100.00% 2 / 2 100.00% 1 / 1 1
 compileDropColumn 100.00% 1 / 1 100.00% 1 / 1 1
 compileSetColumnDefault 100.00% 2 / 2 100.00% 1 / 1 1
 compileDropColumnDefault 100.00% 1 / 1 100.00% 1 / 1 1
 compileModifyColumn 100.00% 9 / 9 100.00% 1 / 1 2
 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
 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 MySQL dialect of the schema grammar.
12 *
13 * Identifiers are quoted with backticks (embedded backticks doubled).
14 * Auto-increment renders as `AUTO_INCREMENT`; MySQL supports dropping
15 * columns natively.
16 */
17final class MySqlSchemaGrammar extends SchemaGrammar
18{
19    /**
20     * Wrap an identifier in MySQL backticks.
21     *
22     * @param  string  $value
23     * @return string
24     */
25    protected function wrap(string $value): string
26    {
27        return '`' . str_replace('`', '``', $value) . '`';
28    }
29
30    /**
31     * Map a logical column type to MySQL's native type.
32     *
33     * Datetime types render fractional seconds when a precision is declared
34     * (`datetime(3)`); MySQL 5.6.4+ stores the declared digits natively.
35     *
36     * @param  ColumnType  $type
37     * @param  int|null  $length
38     * @param  int|null  $precision
39     * @param  int|null  $scale
40     * @return string
41     */
42    public function type(ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): string
43    {
44        return match ($type) {
45            ColumnType::String => 'varchar' . $this->suffix($this->requireLength($length)),
46            ColumnType::Char => 'char' . $this->suffix($this->requireLength($length)),
47            ColumnType::Text => 'text',
48            ColumnType::BigInt => 'bigint',
49            ColumnType::Int => 'int',
50            ColumnType::Decimal => 'decimal' . $this->suffix($precision, $scale),
51            ColumnType::Float => 'double',
52            ColumnType::Boolean => 'tinyint(1)',
53            ColumnType::Date => 'date',
54            ColumnType::DateTime => 'datetime' . $this->suffix($precision),
55            ColumnType::Timestamp => 'timestamp' . $this->suffix($precision),
56            ColumnType::Json => 'json',
57            ColumnType::Enum => 'varchar' . $this->suffix($this->requireLength($length)),
58            ColumnType::Binary => $length === null ? 'blob' : 'varbinary' . $this->suffix($length),
59            ColumnType::Uuid => 'char(36)',
60        };
61    }
62
63    /**
64     * The MySQL auto-increment clause.
65     *
66     * @return string
67     */
68    protected function autoIncrement(): string
69    {
70        return 'AUTO_INCREMENT';
71    }
72
73    /**
74     * MySQL has NO partial (filtered) indexes, NO NULLS NOT DISTINCT, and
75     * NO DEFERRABLE foreign keys — the base clause hooks throw, and MySQL
76     * overrides none of them, so declaring any of those options fails
77     * fast at compile time (Postgres — and for partial indexes SQLite —
78     * render them).
79     */
80
81    /**
82     * MySQL drops an index relative to its table: `ALTER TABLE … DROP INDEX`.
83     *
84     * @param  string  $name
85     * @param  string  $table
86     * @return string
87     */
88    public function compileDropIndex(string $name, string $table): string
89    {
90        $this->assertValidIdentifier($name);
91
92        return 'ALTER TABLE ' . $this->wrap($table) . ' DROP INDEX ' . $this->wrap($name);
93    }
94
95    /**
96     * Compile one column's `DROP COLUMN` clause.
97     *
98     * @param  string  $column
99     * @return string
100     */
101    protected function compileDropColumn(string $column): string
102    {
103        return $this->wrap($column);
104    }
105
106    /**
107     * Compile the statement that restores a column's declared default.
108     *
109     * @param  string  $table
110     * @param  string  $column
111     * @param  mixed  $default  A scalar or an Expression.
112     * @return string
113     */
114    #[\Override]
115    protected function compileSetColumnDefault(string $table, string $column, mixed $default): string
116    {
117        return 'ALTER TABLE ' . $this->wrap($table) . ' ALTER COLUMN ' . $this->wrap($column)
118            . ' SET DEFAULT ' . $this->compileDefault($default);
119    }
120
121    /**
122     * Compile the statement that drops a column's temporary default.
123     *
124     * @param  string  $table
125     * @param  string  $column
126     * @return string
127     */
128    #[\Override]
129    protected function compileDropColumnDefault(string $table, string $column): string
130    {
131        return 'ALTER TABLE ' . $this->wrap($table) . ' ALTER COLUMN ' . $this->wrap($column) . ' DROP DEFAULT';
132    }
133
134    /**
135     * Compile an `ALTER TABLE ... MODIFY COLUMN` statement — MySQL's
136     * in-place content-drift form.
137     *
138     * @param  Blueprint  $blueprint
139     * @return list<string>
140     */
141    public function compileModifyColumn(Blueprint $blueprint): array
142    {
143        $table = $blueprint->getTable();
144        $columns = $blueprint->getColumns();
145        if ($columns === []) {
146            throw new \InvalidArgumentException('Cannot modify columns with no columns defined.');
147        }
148
149        return array_map(
150            fn (array $column): string => 'ALTER TABLE ' . $this->wrap($table)
151                . ' MODIFY ' . $this->compileColumnDefinition($column),
152            $columns,
153        );
154    }
155
156    /**
157     * Compile an `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`
158     * statement — MySQL's in-place FK-add form.
159     *
160     * @param  string  $table
161     * @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
162     * @param  string  $name
163     * @return string
164     */
165    public function compileAddForeignKey(string $table, array $foreignKey, string $name): string
166    {
167        $this->assertValidIdentifier($name);
168
169        return 'ALTER TABLE ' . $this->wrap($table) . ' ADD CONSTRAINT ' . $this->wrap($name) . ' '
170            . $this->compileForeignKeyConstraint($foreignKey);
171    }
172
173    /**
174     * Compile an `ALTER TABLE ... DROP FOREIGN KEY` statement — MySQL's
175     * in-place FK-drop form.
176     *
177     * @param  string  $table
178     * @param  string  $name
179     * @return string
180     */
181    public function compileDropForeignKey(string $table, string $name): string
182    {
183        $this->assertValidIdentifier($name);
184
185        return 'ALTER TABLE ' . $this->wrap($table) . ' DROP FOREIGN KEY ' . $this->wrap($name);
186    }
187
188    /**
189     * Compile an `ALTER TABLE ... ADD CONSTRAINT ... CHECK` statement —
190     * MySQL's in-place CHECK-add form.
191     *
192     * @param  string  $table
193     * @param  string  $name
194     * @param  string  $expression
195     * @return string
196     */
197    public function compileAddCheck(string $table, string $name, string $expression): string
198    {
199        $this->assertValidIdentifier($name);
200
201        return 'ALTER TABLE ' . $this->wrap($table) . ' ADD CONSTRAINT ' . $this->wrap($name)
202            . ' CHECK (' . $expression . ')';
203    }
204
205    /**
206     * MySQL caps identifiers at 64 characters — fail fast at compile
207     * time, never silently truncate.
208     *
209     * @param  string  $name
210     * @return void
211     * @throws \InvalidArgumentException
212     */
213    public function assertValidIdentifier(string $name): void
214    {
215        if (strlen($name) > 64) {
216            throw new \InvalidArgumentException(sprintf(
217                'Identifier [%s] exceeds MySQL\'s 64-character limit (%d chars); '
218                . 'declare a shorter #[Unique(name: ...)] / #[Index(name: ...)].',
219                $name,
220                strlen($name),
221            ));
222        }
223    }
224}