Lines 100.00% 139 / 139
Methods 100.00% 16 / 16
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 getDefaultSchemaGrammar 100.00% 1 / 1 100.00% 1 / 1 1
 columnTypeMatches 100.00% 1 / 1 100.00% 1 / 1 1
 referencingTables 100.00% 10 / 10 100.00% 1 / 1 2
 tables 100.00% 9 / 9 100.00% 1 / 1 2
 table 100.00% 8 / 8 100.00% 1 / 1 2
 columns 100.00% 20 / 20 100.00% 1 / 1 2
 normalizeColumnType 100.00% 1 / 1 100.00% 1 / 1 1
 normalizeColumnDefault 100.00% 1 / 1 100.00% 1 / 1 1
 indexes 100.00% 26 / 26 100.00% 1 / 1 4
 foreignKeys 100.00% 35 / 35 100.00% 1 / 1 3
 normalizeAction 100.00% 2 / 2 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] __construct 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] hasTable 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] castSafety 100.00% 3 / 3 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] liveTypeFamily 100.00% 8 / 8 100.00% 1 / 1 8
 [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] desiredTypeFamily 100.00% 12 / 12 100.00% 1 / 1 7
12class MySqlSchemaInspector extends SchemaInspector
13{
14    /**
15     * The dialect's schema grammar (the factory hook).
16     *
17     * @return \BlueprintAU\Radiant\Database\Schema\Grammars\MySqlSchemaGrammar
18     */
19    protected function getDefaultSchemaGrammar(): \BlueprintAU\Radiant\Database\Schema\Grammars\SchemaGrammar
20    {
21        return new \BlueprintAU\Radiant\Database\Schema\Grammars\MySqlSchemaGrammar();
22    }
23
24    /**
25     * Whether a live column's native type text matches the declared
26     * logical type — the MySQL mapping.
27     *
28     * @param  string  $liveType
29     * @param  \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType  $declaredType
30     * @param  int|null  $declaredLength
31     * @param  int|null  $declaredPrecision
32     * @param  int|null  $declaredScale
33     * @return bool
34     */
35    public function columnTypeMatches(string $liveType, \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $declaredType, int|null $declaredLength, int|null $declaredPrecision = null, int|null $declaredScale = null): bool
36    {
37        return strtolower($liveType) === strtolower($this->schemaGrammar->type($declaredType, $declaredLength, $declaredPrecision, $declaredScale));
38    }
39
40    /**
41     * The live tables that declare a foreign key into the given table.
42     *
43     * @param  string  $table
44     * @return list<string>
45     */
46    public function referencingTables(string $table): array
47    {
48        $statement = $this->pdo->prepare(
49            'SELECT DISTINCT table_name FROM information_schema.key_column_usage '
50            . 'WHERE table_schema = DATABASE() AND referenced_table_name = ? '
51            . 'AND referenced_table_name IS NOT NULL',
52        );
53        $statement->execute([$table]);
54
55        $tables = [];
56        foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $name) {
57            $tables[] = (string) $name;
58        }
59
60        return $tables;
61    }
62    /**
63     * Every table name in the live schema (the connection's default database).
64     *
65     * @return list<string>
66     */
67    public function tables(): array
68    {
69        $statement = $this->pdo->prepare(
70            'SELECT table_name FROM information_schema.tables '
71            . 'WHERE table_schema = DATABASE() ORDER BY table_name',
72        );
73        $statement->execute();
74
75        // Build by append: the append target is inferred as list<string>,
76        // which keeps the return type exact no matter how the underlying
77        // PHP version types fetchAll(PDO::FETCH_COLUMN).
78        $tables = [];
79        foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $name) {
80            $tables[] = (string) $name;
81        }
82
83        return $tables;
84    }
85
86    /**
87     * One table's live schema.
88     *
89     * @param  string  $name
90     * @return LiveTable
91     * @throws \RuntimeException
92     */
93    public function table(string $name): LiveTable
94    {
95        if (!$this->hasTable($name)) {
96            throw new \RuntimeException("Table [{$name}] does not exist in the MySQL schema.");
97        }
98
99        return new LiveTable(
100            $name,
101            $this->columns($name),
102            $this->indexes($name),
103            $this->foreignKeys($name),
104        );
105    }
106
107    /**
108     * The live columns, from `information_schema.columns`.
109     *
110     * @param  string  $name
111     * @return list<array{name: string, type: string, nullable: bool, default: mixed, primaryKey: bool}>
112     */
113    private function columns(string $name): array
114    {
115        $statement = $this->pdo->prepare(
116            'SELECT c.column_name, c.column_type, c.is_nullable, c.column_default, '
117            . '(c.column_key = \'PRI\') AS is_primary '
118            . 'FROM information_schema.columns c '
119            . 'WHERE c.table_schema = DATABASE() AND c.table_name = ? '
120            . 'ORDER BY c.ordinal_position',
121        );
122        $statement->execute([$name]);
123
124        $columns = [];
125
126        /** @var array<string, mixed> $row */
127        foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) {
128            // Native prepares (EMULATE_PREPARES=false) report information_
129            // schema bare-column metadata in UPPERCASE — normalize so the
130            // lowercase reads below work on every driver.
131            $row = array_change_key_case($row, CASE_LOWER);
132            $default = $row['column_default'];
133
134            $columns[] = [
135                'name' => (string) $row['column_name'],
136                'type' => $this->normalizeColumnType(strtolower((string) $row['column_type'])),
137                'nullable' => strtoupper((string) $row['is_nullable']) === 'YES',
138                // MySQL reports CURRENT_TIMESTAMP (and other literals) as
139                // strings; pass through as-is — the differ compares text.
140                'default' => $this->normalizeColumnDefault($default),
141                'primaryKey' => ((int) $row['is_primary']) === 1,
142            ];
143        }
144
145        return $columns;
146    }
147
148    /**
149     * Normalize a live column type's dialect spelling.
150     *
151     * @param  string  $type
152     * @return string
153     */
154    protected function normalizeColumnType(string $type): string
155    {
156        return $type;
157    }
158
159    /**
160     * Normalize a live column default's dialect spelling.
161     *
162     * @param  mixed  $default
163     * @return mixed
164     */
165    protected function normalizeColumnDefault(mixed $default): mixed
166    {
167        return $default;
168    }
169
170    /**
171     * The live indexes, from `information_schema.statistics`.
172     *
173     * @param  string  $name
174     * @return list<array{name: string|null, columns: list<string>, unique: bool, where: string|null, nullsNotDistinct: bool}>
175     */
176    private function indexes(string $name): array
177    {
178        $statement = $this->pdo->prepare(
179            'SELECT index_name, column_name, non_unique, seq_in_index '
180            . 'FROM information_schema.statistics '
181            . 'WHERE table_schema = DATABASE() AND table_name = ? '
182            . 'ORDER BY index_name, seq_in_index',
183        );
184        $statement->execute([$name]);
185
186        /** @var list<array<string, mixed>> $rows */
187        $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
188
189        $groups = [];
190
191        foreach ($rows as $row) {
192            // Native prepares report information_schema keys in UPPERCASE.
193            $row = array_change_key_case($row, CASE_LOWER);
194            $indexName = (string) $row['index_name'];
195
196            // PRIMARY rides the columns' primaryKey flag, not the index list.
197            if ($indexName === 'PRIMARY') {
198                continue;
199            }
200
201            $groups[$indexName]['columns'][(int) $row['seq_in_index']] = (string) $row['column_name'];
202            $groups[$indexName]['unique'] = ((int) $row['non_unique']) === 0;
203        }
204
205        $indexes = [];
206
207        foreach ($groups as $indexName => $group) {
208            $indexes[] = [
209                'name' => $indexName,
210                'columns' => array_values($group['columns']),
211                'unique' => $group['unique'],
212                'where' => null,
213                'nullsNotDistinct' => false,
214            ];
215        }
216
217        return $indexes;
218    }
219
220    /**
221     * The live foreign keys, from `information_schema.key_column_usage` +
222     * `referential_constraints` (for the actions).
223     *
224     * @param  string  $name
225     * @return list<array{columns: list<string>, referencesTable: string, referencesColumns: list<string>, onDelete: string|null, onUpdate: string|null, deferrable: bool, name: string}>
226     */
227    private function foreignKeys(string $name): array
228    {
229        $statement = $this->pdo->prepare(
230            'SELECT kcu.constraint_name, kcu.column_name, kcu.referenced_table_name, '
231            . 'kcu.referenced_column_name, kcu.ordinal_position, '
232            . 'rc.delete_rule, rc.update_rule '
233            . 'FROM information_schema.key_column_usage kcu '
234            . 'JOIN information_schema.referential_constraints rc '
235            . 'ON rc.constraint_name = kcu.constraint_name '
236            . 'AND rc.constraint_schema = kcu.constraint_schema '
237            . 'WHERE kcu.table_schema = DATABASE() AND kcu.table_name = ? '
238            . 'AND kcu.referenced_table_name IS NOT NULL '
239            . 'ORDER BY kcu.constraint_name, kcu.ordinal_position',
240        );
241        $statement->execute([$name]);
242
243        /** @var list<array<string, mixed>> $rows */
244        $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
245
246        $groups = [];
247
248        foreach ($rows as $row) {
249            // Native prepares report information_schema keys in UPPERCASE.
250            $row = array_change_key_case($row, CASE_LOWER);
251            $constraintName = (string) $row['constraint_name'];
252            $groups[$constraintName]['columns'][] = (string) $row['column_name'];
253            $groups[$constraintName]['referencesTable'] = (string) $row['referenced_table_name'];
254            $groups[$constraintName]['referencesColumns'][(int) $row['ordinal_position']] = (string) $row['referenced_column_name'];
255            $groups[$constraintName]['onDelete'] = $row['delete_rule'];
256            $groups[$constraintName]['onUpdate'] = $row['update_rule'];
257        }
258
259        $constraints = [];
260
261        foreach ($groups as $constraintName => $group) {
262            $constraints[] = [
263                'columns' => $group['columns'],
264                'referencesTable' => $group['referencesTable'],
265                'referencesColumns' => array_values($group['referencesColumns']),
266                'onDelete' => $this->normalizeAction($group['onDelete']),
267                'onUpdate' => $this->normalizeAction($group['onUpdate']),
268                // MySQL has no DEFERRABLE — always false.
269                'deferrable' => false,
270                // The live constraint name — the drop handle.
271                'name' => $constraintName,
272            ];
273        }
274
275        return $constraints;
276    }
277
278    /**
279     * Normalize MySQL's referential-action text to a canonical value.
280     *
281     * @param  mixed  $action
282     * @return string|null
283     */
284    private function normalizeAction(mixed $action): ?string
285    {
286        $normalized = strtoupper(trim((string) $action));
287
288        return $normalized === 'NO ACTION' ? null : $normalized;
289    }
290}

Inherited from BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector

28    public function __construct(
29        protected readonly \PDO $pdo,
30    ) {
31        $this->schemaGrammar = $this->getDefaultSchemaGrammar();
32    }
63    final public function hasTable(string $name): bool
64    {
65        return in_array($name, $this->tables(), true);
66    }
101    public function castSafety(string $liveType, \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $desiredType): \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety
102    {
103        return $this->liveTypeFamily($liveType) === $this->desiredTypeFamily($desiredType)
104            ? \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety::Safe
105            : \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety::Risky;
106    }
114    protected function liveTypeFamily(string $liveType): string
115    {
116        $base = strtolower(preg_replace('/\(.*$/', '', trim($liveType)) ?? $liveType);
117
118        return match (true) {
119            str_contains($base, 'int') => 'number',
120            in_array($base, ['numeric', 'decimal', 'float', 'double', 'double precision', 'real'], true) => 'number',
121            in_array($base, ['bool', 'boolean'], true) => 'bool',
122            in_array($base, ['date', 'time', 'timestamp', 'timestamptz', 'datetime'], true) => 'temporal',
123            in_array($base, ['json', 'jsonb'], true) => 'json',
124            in_array($base, ['blob', 'bytea', 'binary', 'varbinary'], true) => 'binary',
125            default => 'string',
126        };
127    }
135    protected function desiredTypeFamily(\BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $desiredType): string
136    {
137        return match ($desiredType) {
138            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Int,
139            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::BigInt,
140            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Decimal,
141            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Float => 'number',
142            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Boolean => 'bool',
143            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Date,
144            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::DateTime,
145            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Timestamp => 'temporal',
146            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Json => 'json',
147            \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Binary => 'binary',
148            default => 'string',
149        };
150    }