Passed
Push — dbal ( a67a72...c31592 )
by Greg
08:19
created

MySQLDriver::introspectSchema()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 38
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 24
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 38
rs 9.536
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2022 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\DB\Drivers;
21
22
use Fisharebest\Webtrees\DB\Exceptions\SchemaException;
23
use Fisharebest\Webtrees\DB\Expression;
24
use Fisharebest\Webtrees\DB\Schema\BinaryColumn;
25
use Fisharebest\Webtrees\DB\Schema\BlobColumn;
26
use Fisharebest\Webtrees\DB\Schema\CharacterColumn;
27
use Fisharebest\Webtrees\DB\Schema\ColumnInterface;
28
use Fisharebest\Webtrees\DB\Schema\EnumColumn;
29
use Fisharebest\Webtrees\DB\Schema\FloatColumn;
30
use Fisharebest\Webtrees\DB\Schema\IntegerColumn;
31
use Fisharebest\Webtrees\DB\Schema\Schema;
32
use Fisharebest\Webtrees\DB\Schema\Table;
33
use Fisharebest\Webtrees\DB\Schema\TextColumn;
34
use Fisharebest\Webtrees\DB\Schema\TimestampColumn;
35
use Fisharebest\Webtrees\DB\Schema\UuidColumn;
36
use LogicException;
37
38
use function get_class;
39
use function implode;
40
use function is_string;
41
use function preg_match;
42
use function str_contains;
43
use function str_starts_with;
44
45
/**
46
 * Driver for MySQL
47
 */
48
class MySQLDriver extends AbstractDriver implements DriverInterface
49
{
50
    protected const IDENTIFIER_OPEN_QUOTE  = '`';
51
    protected const IDENTIFIER_CLOSE_QUOTE = '`';
52
53
    private const INTEGER_TYPES = [
54
        8  => 'TINYINT',
55
        16 => 'SMALLINT',
56
        24 => 'MEDIUMINT',
57
        32 => 'INT',
58
        64 => 'BIGINT',
59
    ];
60
61
    private const TEXT_TYPES = [
62
        1 => 'TINYTEXT',
63
        2 => 'SMALLTEXT',
64
        3 => 'MEDIUMTEXT',
65
        4 => 'TEXT',
66
    ];
67
68
    private const BLOB_TYPES = [
69
        1 => 'TINYBLOB',
70
        2 => 'SMALLBLOB',
71
        3 => 'MEDIUMBLOB',
72
        4 => 'BLOB',
73
    ];
74
75
    /**
76
     * @param string|null $schema_name
77
     *
78
     * @return Schema
79
     * @throws SchemaException
80
     */
81
    public function introspectSchema(string $schema_name = null): Schema
82
    {
83
        $schema_name     ??= $this->query(sql: 'SELECT DATABASE() AS schema_name')[0]->schema_name;
84
        $pattern         = $this->escapeLike(string: $this->prefix) . '%';
85
        $table_data      = $this->informationSchemaTables(schema_name: $schema_name, pattern: $pattern);
86
        $column_data     = $this->informationSchemaColumns(schema_name: $schema_name, pattern: $pattern);
87
        $constraint_data = $this->informationSchemaTableConstraints(schema_name: $schema_name, pattern: $pattern);
88
        $index_data      = $this->informationSchemaKeyColumnUsage(schema_name: $schema_name, pattern: $pattern);
89
90
        $schema = new Schema();
91
92
        foreach ($table_data as $table_datum) {
93
            $table = new Table($table_datum->TABLE_NAME);
94
95
            foreach ($column_data as $row) {
96
                if ($row->TABLE_NAME === $table_datum->TABLE_NAME) {
97
                    $table->addColumn($this->createColumn($row));
98
                }
99
            }
100
101
            $schema->addTable($table);
102
        }
103
104
        return $schema;
105
    }
106
107
    /**
108
     * @param Table $table
109
     *
110
     * @return string
111
     */
112
    public function createTableSQL(Table $table, bool $add_prefix): string
113
    {
114
        $table_name = $table->getName();
115
116
        if ($add_prefix) {
117
            $table_name = $this->prefix . $table_name;
118
        }
119
120
        $columns = array_map(fn(ColumnInterface $column): string => $this->columnSQL($column), $table->getColumns());
121
122
        return 'CREATE TABLE ' . $this->quoteIdentifier($table_name) . ' (' . implode(', ', $columns) . ')';
123
    }
124
125
    /**
126
     * @param ColumnInterface $column
127
     *
128
     * @return string
129
     */
130
    private function columnSQL(ColumnInterface $column): string
131
    {
132
        $sql = $this->quoteIdentifier($column->getName()) . ' ';
133
134
        if ($column instanceof BinaryColumn) {
135
            $sql .= $column->varying ? 'VARBINARY' : 'BINARY';
136
            $sql .= '(' . $column->length . ')';
137
        } elseif ($column instanceof BlobColumn) {
138
            $sql .= self::BLOB_TYPES[$column->length];
139
        } elseif ($column instanceof CharacterColumn) {
140
            $sql .= $column->varying ? 'VARCHAR' : 'CHAR';
141
            $sql .= '(' . $column->length . ') COLLATE ';
142
            $sql .= $column->national ? $this->utf8Charset() : 'ascii';
143
            $sql .= '_bin';
144
        } elseif ($column instanceof FloatColumn) {
145
            $sql .= $column->precision_bits > 23 ? 'DOUBLE' : 'FLOAT';
146
        } elseif ($column instanceof IntegerColumn) {
147
            $sql .= self::INTEGER_TYPES[$column->bits];
148
            $sql .= $column->auto_increment ? ' AUTO_INCREMENT' : '';
149
        } elseif ($column instanceof TextColumn) {
150
            $sql .= self::TEXT_TYPES[$column->length] . ' COLLATE ' . $this->utf8Charset() . '_bin';
151
        } elseif ($column instanceof TimestampColumn) {
152
            $sql .= 'TIMESTAMP';
153
            $sql .= $column->precision === 0 ? '' : '(' . $column->precision . ')';
154
        } elseif ($column instanceof UuidColumn) {
155
            $sql .= 'CHAR(36) COLLATE ascii_bin';
156
        } elseif ($column instanceof EnumColumn) {
157
            $sql .= 'ENUM(' . implode(',', array_map(self::quoteValue(...), $column->values())) . ')';
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected ')' on line 157 at column 73
Loading history...
158
        } else {
159
            throw new LogicException('Driver ' . self::class . ' has no definition for ' . get_class($column));
160
        }
161
162
        if ($column->isNullable()) {
163
            $sql .= ' NULL';
164
        }
165
166
        if ($column->getDefault() instanceof Expression || is_numeric($column->getDefault())) {
167
            $sql .= ' DEFAULT ' . $column->getDefault();
168
        } elseif (is_string($column->getDefault())) {
169
            $sql .= ' DEFAULT ' . $this->quoteValue($column->getDefault());
170
        }
171
172
        if ($column->isInvisible() && version_compare($this->server_version, '8.0.23') >= 0) {
173
            $sql .= ' INVISIBLE';
174
        }
175
176
        if ($column->getComment() !== '') {
177
            $sql .= ' COMMENT ' . $this->quoteValue($column->getComment());
178
        }
179
180
        return $sql;
181
    }
182
183
    /**
184
     * Generate a Column object from a row from INFORMATION_SCHEMA.COLUMNS
185
     *
186
     * @param object $row
187
     *
188
     * @return ColumnInterface
189
     * @throws SchemaException
190
     */
191
    private function createColumn(object $row): ColumnInterface
192
    {
193
        $name           = $row->COLUMN_NAME;
194
        $type           = $row->COLUMN_TYPE;
195
        $collation      = $row->COLLATION_NAME;
196
        $nullable       = $row->IS_NULLABLE === 'YES';
197
        $default        = $row->COLUMN_DEFAULT;
198
        $invisible      = str_contains($row->EXTRA, 'INVISIBLE');
199
        $auto_increment = str_contains($row->EXTRA, 'auto_increment');
200
201
        if (preg_match('/^bit\((\d+)\)$/', $type, $match) === 1) {
202
            $column = Schema::bit(name: $name, bits: (int) $match[1]);
203
        } elseif (preg_match('/^decimal\((\d+),(\d+)\)$/', $type, $match) === 1) {
204
            $column = Schema::decimal(name: $name, precision: (int) $match[1], scale: (int) $match[2]);
205
        } elseif (preg_match('/^varchar\((\d+)\)$/', $type, $match) === 1) {
206
            if (str_starts_with($collation ?? '', 'ascii')) {
207
                $column = Schema::varchar(name: $name, length: (int) $match[1]);
208
            } else {
209
                $column = Schema::nVarchar(name: $name, length: (int) $match[1]);
210
            }
211
        } elseif (preg_match('/^char\((\d+)\)$/', $type, $match) === 1) {
212
            if (str_starts_with($collation ?? '', 'ascii')) {
213
                $column = Schema::char(name: $name, length: (int) $match[1]);
214
            } else {
215
                $column = Schema::nChar(name: $name, length: (int) $match[1]);
216
            }
217
        } elseif (preg_match('/^enum\(\'(.+)\'\)$/', $type, $match) === 1) {
218
            $values = preg_split("/(?<!')','(?!')/", $match[1]);
219
            $values = array_map(static fn (string $s): string => strtr($s, ["''" => "'"]), $values);
220
221
            $column = Schema::enum(name: $name, values: $values);
222
        } else {
223
            $column = match ($type) {
224
                'bigint'              => Schema::bigInteger(name: $name)->autoIncrement($auto_increment),
225
                'bigint unsigned'     => Schema::bigInteger(name: $name)->unsigned()->autoIncrement($auto_increment),
226
                'blob'                => Schema::blob(name: $name, length: 2),
227
                'date'                => Schema::date(name: $name),
228
                'datetime',
229
                'datetime(0)'         => Schema::datetime(name: $name),
230
                'datetime(1)'         => Schema::datetime(name: $name, length: 1),
231
                'datetime(2)'         => Schema::datetime(name: $name, length: 2),
232
                'datetime(3)'         => Schema::datetime(name: $name, length: 3),
233
                'datetime(4)'         => Schema::datetime(name: $name, length: 4),
234
                'datetime(5)'         => Schema::datetime(name: $name, length: 5),
235
                'datetime(6)'         => Schema::datetime(name: $name, length: 6),
236
                'double'              => Schema::double(name: $name),
237
                'float'               => Schema::float(name: $name),
238
                'geometry'            => Schema::geometry(name: $name),
239
                'geometrycollection'  => Schema::geometrycollection(name: $name),
240
                'int'                 => Schema::integer(name: $name)->autoIncrement($auto_increment),
241
                'int unsigned'        => Schema::integer(name: $name)->unsigned()->autoIncrement($auto_increment),
242
                'json'                => Schema::json(name: $name),
243
                'linestring'          => Schema::linestring(name: $name),
244
                'longblob'            => Schema::blob(name: $name),
245
                'longtext'            => Schema::text(name: $name),
246
                'mediumblob'          => Schema::blob(name: $name, length: 3),
247
                'mediumint'           => Schema::mediumInteger(name: $name)->autoIncrement($auto_increment),
248
                'mediumint unsigned'  => Schema::mediumInteger(name: $name)->unsigned()->autoIncrement($auto_increment),
249
                'mediumtext'          => Schema::text(name: $name, length: 3),
250
                'multilinestring'     => Schema::multilinestring(name: $name),
251
                'multipoint'          => Schema::multipoint(name: $name),
252
                'multipolygon'        => Schema::multipolygon(name: $name),
253
                'point'               => Schema::point(name: $name),
254
                'polygon'             => Schema::polygon(name: $name),
255
                'smallint'            => Schema::smallInteger(name: $name)->autoIncrement($auto_increment),
256
                'smallint unsigned'   => Schema::smallInteger(name: $name)->unsigned()->autoIncrement($auto_increment),
257
                'text'                => Schema::text(name: $name, length: 2),
258
                'time'                => Schema::time(name: $name),
259
                'timestamp',
260
                'timestamp(0)'        => Schema::timestamp(name: $name),
261
                'timestamp(1)'        => Schema::timestamp(name: $name, length: 1),
262
                'timestamp(2)'        => Schema::timestamp(name: $name, length: 2),
263
                'timestamp(3)'        => Schema::timestamp(name: $name, length: 3),
264
                'timestamp(4)'        => Schema::timestamp(name: $name, length: 4),
265
                'timestamp(5)'        => Schema::timestamp(name: $name, length: 5),
266
                'timestamp(6)'        => Schema::timestamp(name: $name, length: 6),
267
                'tinyblob'            => Schema::blob(name: $name, length: 1),
268
                'tinyint',
269
                'tinyint(1)'          => Schema::tinyInteger(name: $name)->autoIncrement($auto_increment),
270
                'tinyint unsigned',
271
                'tinyint(1) unsigned' => Schema::tinyInteger(name: $name)->unsigned()->autoIncrement($auto_increment),
272
                'tinytext'            => Schema::text(name: $name, length: 1),
273
                'year'                => Schema::year(name: $name),
274
                default               => throw new SchemaException('Unknown type ' . $type . ' for column ' . $name),
275
            };
276
        }
277
278
279
        if (
280
            $default === 'CURRENT_TIMESTAMP' ||
281
            $default === 'CURRENT_TIMESTAMP(1)' ||
282
            $default === 'CURRENT_TIMESTAMP(2)' ||
283
            $default === 'CURRENT_TIMESTAMP(3)' ||
284
            $default === 'CURRENT_TIMESTAMP(4)' ||
285
            $default === 'CURRENT_TIMESTAMP(5)' ||
286
            $default === 'CURRENT_TIMESTAMP(6)'
287
        ) {
288
            $default = new Expression($default);
289
        }
290
291
        return $column
292
            ->nullable($nullable)
293
            ->default($default)
294
            ->invisible($invisible);
295
    }
296
297
    /**
298
     * @param string $schema_name
299
     * @param string $pattern
300
     *
301
     * @return array<object>
302
     */
303
    private function informationSchemaTables(string $schema_name, string $pattern): array
304
    {
305
        $sql =
306
            'SELECT *  FROM INFORMATION_SCHEMA.TABLES' .
307
            ' WHERE    TABLE_TYPE = :table_type AND TABLE_SCHEMA = :table_schema AND TABLE_NAME LIKE :pattern' .
308
            ' ORDER BY TABLE_NAME';
309
310
        return $this->query(sql: $sql, bindings: ['table_type' => 'BASE TABLE', 'table_schema' => $schema_name, 'pattern' => $pattern]);
311
    }
312
313
    /**
314
     * @param string $schema_name
315
     * @param string $pattern
316
     *
317
     * @return array<object>
318
     */
319
    private function informationSchemaColumns(string $schema_name, string $pattern): array
320
    {
321
        $sql =
322
            'SELECT *  FROM INFORMATION_SCHEMA.COLUMNS' .
323
            ' WHERE    TABLE_SCHEMA = :table_schema AND TABLE_NAME LIKE :pattern' .
324
            ' ORDER BY ORDINAL_POSITION';
325
326
        return $this->query(sql: $sql, bindings: ['table_schema' => $schema_name, 'pattern' => $pattern]);
327
    }
328
329
    /**
330
     * @param string $schema_name
331
     * @param string $pattern
332
     *
333
     * @return array<object>
334
     */
335
    private function informationSchemaTableConstraints(string $schema_name, string $pattern): array
336
    {
337
        $sql =
338
            'SELECT *  FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS' .
339
            ' WHERE    TABLE_SCHEMA = :table_schema AND TABLE_NAME LIKE :pattern';
340
341
        return $this->query(sql: $sql, bindings: ['table_schema' => $schema_name, 'pattern' => $pattern]);
342
    }
343
344
    /**
345
     * @param string $schema_name
346
     * @param string $pattern
347
     *
348
     * @return array<object>
349
     */
350
    private function informationSchemaKeyColumnUsage(string $schema_name, string $pattern): array
351
    {
352
        $sql =
353
            'SELECT *  FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE' .
354
            ' WHERE    TABLE_SCHEMA = :table_schema AND TABLE_NAME LIKE :pattern' .
355
            ' ORDER BY ORDINAL_POSITION';
356
357
        return $this->query(sql: $sql, bindings: ['table_schema' => $schema_name, 'pattern' => $pattern]);
358
    }
359
360
    /**
361
     * Does this database support utf8mb4 or utf8mb3?
362
     *
363
     * @return string
364
     */
365
    private function utf8Charset(): string
366
    {
367
        // MariaDB 10.2 and later
368
        if (version_compare($this->server_version, '10.2') >= 0) {
369
            return 'utf8mb4';
370
        }
371
372
        // MySQL 5.7 and 8.0
373
        if (version_compare($this->server_version, '5.7') >= 0 && version_compare($this->server_version, '10.0') < 0) {
374
            return 'utf8mb4';
375
        }
376
377
        return 'utf8mb3';
378
    }
379
}
380