Lines 100.00% 152 / 152
Methods 100.00% 16 / 16
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 normalizeColumnType 100.00% 3 / 3 100.00% 1 / 1 2
 columnTypeMatches 100.00% 6 / 6 100.00% 1 / 1 4
 castSafety 100.00% 4 / 4 100.00% 1 / 1 3
 normalizeColumnDefault 100.00% 6 / 6 100.00% 1 / 1 5
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] getDefaultSchemaGrammar 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] referencingTables 100.00% 10 / 10 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] tables 100.00% 9 / 9 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] table 100.00% 8 / 8 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] columns 100.00% 20 / 20 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] indexes 100.00% 26 / 26 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] foreignKeys 100.00% 35 / 35 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Schema\Inspectors\MySqlSchemaInspector] 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] 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
28final class MariaDbSchemaInspector extends MySqlSchemaInspector
29{
30    /**
31     * The MariaDB spellings of a current-timestamp default.
32     *
33     * @var list<string>
34     */
35    private const CURRENT_TIMESTAMP_SPELLINGS = ['current_timestamp()', 'now()'];
36
37    /**
38     * Normalize a live column type's MariaDB spelling.
39     *
40     * MariaDB retains integer display widths that MySQL 8 dropped
41     * (`bigint(20)` where MySQL reports `bigint`) — the widths have no
42     * semantic effect, so they are stripped. The exception is
43     * `tinyint(1)`: it IS the Boolean rendering, not a display width,
44     * and the type comparison needs it verbatim.
45     *
46     * @param  string  $type
47     * @return string
48     */
49    #[Override]
50    protected function normalizeColumnType(string $type): string
51    {
52        if (strtolower($type) === 'tinyint(1)') {
53            return 'tinyint(1)';
54        }
55
56        return preg_replace('/^(tinyint|smallint|mediumint|bigint|int)\(\d+\)/', '$1', $type) ?? $type;
57    }
58
59    /**
60     * Whether a live column's native type text matches the declared
61     * logical type — the MariaDB mapping over the MySQL one.
62     *
63     * @param  string  $liveType
64     * @param  ColumnType  $declaredType
65     * @param  int|null  $declaredLength
66     * @param  int|null  $declaredPrecision
67     * @param  int|null  $declaredScale
68     * @return bool
69     */
70    #[Override]
71    public function columnTypeMatches(string $liveType, ColumnType $declaredType, int|null $declaredLength, int|null $declaredPrecision = null, int|null $declaredScale = null): bool
72    {
73        // MariaDB stores JSON as LONGTEXT: a declared Json column reads
74        // back as `longtext` (no length suffix).
75        if ($declaredType === ColumnType::Json
76            && strtolower($liveType) === 'longtext'
77            && $declaredLength === null) {
78            return true;
79        }
80
81        // MariaDB retains integer display widths MySQL 8 dropped — strip
82        // them so the comparison sees the bare type text.
83        $liveType = $this->normalizeColumnType(strtolower($liveType));
84
85        return parent::columnTypeMatches($liveType, $declaredType, $declaredLength, $declaredPrecision, $declaredScale);
86    }
87
88    /**
89     * The MariaDB modify-cast classification — a `longtext` column
90     * converts to a Json column exactly as MariaDB does when it applies
91     * its json_valid CHECK.
92     *
93     * @param  string  $liveType
94     * @param  ColumnType  $desiredType
95     * @return CastSafety
96     */
97    #[Override]
98    public function castSafety(string $liveType, ColumnType $desiredType): CastSafety
99    {
100        // The family comparisons see the same stripped text the differ
101        // read from the columns.
102        $liveType = $this->normalizeColumnType(strtolower($liveType));
103
104        if ($desiredType === ColumnType::Json && $liveType === 'longtext') {
105            return CastSafety::Safe;
106        }
107
108        return parent::castSafety($liveType, $desiredType);
109    }
110
111    /**
112     * Normalize a live column default's MariaDB spelling.
113     *
114     * MariaDB renders `current_timestamp()` (and `now()`) where MySQL
115     * reports `CURRENT_TIMESTAMP`, and reports the literal string
116     * `'NULL'` for a nullable column with no default where MySQL reports
117     * a real null.
118     *
119     * @param  mixed  $default
120     * @return mixed
121     */
122    #[\Override]
123    protected function normalizeColumnDefault(mixed $default): mixed
124    {
125        // A nullable column with no default reports the literal string
126        // 'NULL' — normalize to the real null MySQL reports.
127        if (is_string($default) && strtoupper($default) === 'NULL') {
128            return null;
129        }
130
131        if (!is_string($default)
132            || !in_array(strtolower(trim($default)), self::CURRENT_TIMESTAMP_SPELLINGS, true)) {
133            return $default;
134        }
135
136        return 'CURRENT_TIMESTAMP';
137    }
138}

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

19    protected function getDefaultSchemaGrammar(): \BlueprintAU\Radiant\Database\Schema\Grammars\SchemaGrammar
20    {
21        return new \BlueprintAU\Radiant\Database\Schema\Grammars\MySqlSchemaGrammar();
22    }
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    }
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    }
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    }
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    }
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    }
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    }
284    private function normalizeAction(mixed $action): ?string
285    {
286        $normalized = strtoupper(trim((string) $action));
287
288        return $normalized === 'NO ACTION' ? null : $normalized;
289    }

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    }
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    }