Passed
Push — master ( 1265f7...db8617 )
by Alexander
01:41
created

Schema   F

Complexity

Total Complexity 68

Size/Duplication

Total Lines 521
Duplicated Lines 0 %

Test Coverage

Coverage 91.96%

Importance

Changes 0
Metric Value
eloc 219
c 0
b 0
f 0
dl 0
loc 521
ccs 183
cts 199
cp 0.9196
rs 2.96
wmc 68

18 Methods

Rating   Name   Duplication   Size   Complexity  
A findUniqueIndexes() 0 21 4
A createQueryBuilder() 0 3 1
A loadTableIndexes() 0 3 1
A loadTableUniques() 0 3 1
A loadTableForeignKeys() 0 27 2
A setTransactionIsolationLevel() 0 12 3
A findColumns() 0 23 6
B loadTableChecks() 0 41 6
A findTableNames() 0 5 1
A createColumnSchemaBuilder() 0 3 1
C loadColumnSchema() 0 49 17
A isSystemIdentifier() 0 3 1
A findConstraints() 0 12 3
A loadTableColumnsInfo() 0 9 1
F loadTableConstraints() 0 90 16
A loadTablePrimaryKey() 0 3 1
A loadTableSchema() 0 14 2
A loadTableDefaultValues() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Schema often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Schema, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Sqlite\Schema;
6
7
use Yiisoft\Arrays\ArrayHelper;
8
use Yiisoft\Db\Constraints\CheckConstraint;
9
use Yiisoft\Db\Constraints\Constraint;
10
use Yiisoft\Db\Constraints\ConstraintFinderInterface;
11
use Yiisoft\Db\Constraints\ConstraintFinderTrait;
12
use Yiisoft\Db\Constraints\ForeignKeyConstraint;
13
use Yiisoft\Db\Constraints\IndexConstraint;
14
use Yiisoft\Db\Exceptions\NotSupportedException;
15
use Yiisoft\Db\Expressions\Expression;
16
use Yiisoft\Db\Schemas\Schema as AbstractSchema;
17
use Yiisoft\Db\Schemas\ColumnSchema;
18
use Yiisoft\Db\Schemas\TableSchema;
19
use Yiisoft\Db\Sqlite\Query\QueryBuilder;
20
use Yiisoft\Db\Sqlite\Token\SqlToken;
21
use Yiisoft\Db\Sqlite\Token\SqlTokenizer;
22
use Yiisoft\Db\Transactions\Transaction;
23
24
/**
25
 * Schema is the class for retrieving metadata from a SQLite (2/3) database.
26
 *
27
 * @property string $transactionIsolationLevel The transaction isolation level to use for this transaction.
28
 * This can be either {@see Transaction::READ_UNCOMMITTED} or {@see Transaction::SERIALIZABLE}.
29
 */
30
class Schema extends AbstractSchema implements ConstraintFinderInterface
31
{
32
    use ConstraintFinderTrait;
33
34
    /**
35
     * @var array mapping from physical column types (keys) to abstract column types (values)
36
     */
37
    public array $typeMap = [
38
        'tinyint' => self::TYPE_TINYINT,
39
        'bit' => self::TYPE_SMALLINT,
40
        'boolean' => self::TYPE_BOOLEAN,
41
        'bool' => self::TYPE_BOOLEAN,
42
        'smallint' => self::TYPE_SMALLINT,
43
        'mediumint' => self::TYPE_INTEGER,
44
        'int' => self::TYPE_INTEGER,
45
        'integer' => self::TYPE_INTEGER,
46
        'bigint' => self::TYPE_BIGINT,
47
        'float' => self::TYPE_FLOAT,
48
        'double' => self::TYPE_DOUBLE,
49
        'real' => self::TYPE_FLOAT,
50
        'decimal' => self::TYPE_DECIMAL,
51
        'numeric' => self::TYPE_DECIMAL,
52
        'tinytext' => self::TYPE_TEXT,
53
        'mediumtext' => self::TYPE_TEXT,
54
        'longtext' => self::TYPE_TEXT,
55
        'text' => self::TYPE_TEXT,
56
        'varchar' => self::TYPE_STRING,
57
        'string' => self::TYPE_STRING,
58
        'char' => self::TYPE_CHAR,
59
        'blob' => self::TYPE_BINARY,
60
        'datetime' => self::TYPE_DATETIME,
61
        'year' => self::TYPE_DATE,
62
        'date' => self::TYPE_DATE,
63
        'time' => self::TYPE_TIME,
64
        'timestamp' => self::TYPE_TIMESTAMP,
65
        'enum' => self::TYPE_STRING,
66
    ];
67
68
    /**
69
     * {@inheritdoc}
70
     */
71
    protected string $tableQuoteCharacter = '`';
72
73
    /**
74
     * {@inheritdoc}
75
     */
76
    protected string $columnQuoteCharacter = '`';
77
78
    /**
79
     * {@inheritdoc}
80
     */
81 5
    protected function findTableNames($schema = '')
82
    {
83 5
        $sql = "SELECT DISTINCT tbl_name FROM sqlite_master WHERE tbl_name<>'sqlite_sequence' ORDER BY tbl_name";
84
85 5
        return $this->db->createCommand($sql)->queryColumn();
0 ignored issues
show
Bug introduced by
The method createCommand() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

85
        return $this->db->/** @scrutinizer ignore-call */ createCommand($sql)->queryColumn();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
86
    }
87
88
    /**
89
     * {@inheritdoc}
90
     */
91 77
    protected function loadTableSchema($name)
92
    {
93 77
        $table = new TableSchema();
94
95 77
        $table->name = $name;
96 77
        $table->fullName = $name;
97
98 77
        if ($this->findColumns($table)) {
99 75
            $this->findConstraints($table);
100
101 75
            return $table;
102
        }
103
104 10
        return null;
105
    }
106
107
    /**
108
     * {@inheritdoc}
109
     */
110 30
    protected function loadTablePrimaryKey($tableName)
111
    {
112 30
        return $this->loadTableConstraints($tableName, 'primaryKey');
113
    }
114
115
    /**
116
     * {@inheritdoc}
117
     */
118 3
    protected function loadTableForeignKeys($tableName)
119
    {
120 3
        $foreignKeys = $this->db->createCommand(
121 3
            'PRAGMA FOREIGN_KEY_LIST (' . $this->quoteValue($tableName) . ')'
122 3
        )->queryAll();
123
124 3
        $foreignKeys = $this->normalizePdoRowKeyCase($foreignKeys, true);
125
126 3
        $foreignKeys = ArrayHelper::index($foreignKeys, null, 'table');
127
128 3
        ArrayHelper::multisort($foreignKeys, 'seq', SORT_ASC, SORT_NUMERIC);
129
130 3
        $result = [];
131
132 3
        foreach ($foreignKeys as $table => $foreignKey) {
133 3
            $fk = new ForeignKeyConstraint();
134
135 3
            $fk->setColumnNames(ArrayHelper::getColumn($foreignKey, 'from'));
136 3
            $fk->setForeignTableName($table);
137 3
            $fk->setForeignColumnNames(ArrayHelper::getColumn($foreignKey, 'to'));
138 3
            $fk->setOnDelete($foreignKey[0]['on_delete'] ?? null);
139 3
            $fk->setOnUpdate($foreignKey[0]['on_update'] ?? null);
140
141 3
            $result[] = $fk;
142
        }
143
144 3
        return $result;
145
    }
146
147
    /**
148
     * {@inheritdoc}
149
     */
150 10
    protected function loadTableIndexes($tableName)
151
    {
152 10
        return $this->loadTableConstraints($tableName, 'indexes');
153
    }
154
155
    /**
156
     * {@inheritdoc}
157
     */
158 12
    protected function loadTableUniques($tableName)
159
    {
160 12
        return $this->loadTableConstraints($tableName, 'uniques');
161
    }
162
163
    /**
164
     * {@inheritdoc}
165
     */
166 12
    protected function loadTableChecks($tableName)
167
    {
168 12
        $sql = $this->db->createCommand('SELECT `sql` FROM `sqlite_master` WHERE name = :tableName', [
169 12
            ':tableName' => $tableName,
170 12
        ])->queryScalar();
171
172
        /** @var $code SqlToken[]|SqlToken[][]|SqlToken[][][] */
173 12
        $code = (new SqlTokenizer($sql))->tokenize();
0 ignored issues
show
Bug introduced by
It seems like $sql can also be of type false and null; however, parameter $sql of Yiisoft\Db\Sqlite\Token\...okenizer::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

173
        $code = (new SqlTokenizer(/** @scrutinizer ignore-type */ $sql))->tokenize();
Loading history...
174 12
        $pattern = (new SqlTokenizer('any CREATE any TABLE any()'))->tokenize();
175
176 12
        if (!$code[0]->matches($pattern, 0, $firstMatchIndex, $lastMatchIndex)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $lastMatchIndex seems to be never defined.
Loading history...
Comprehensibility Best Practice introduced by
The variable $firstMatchIndex seems to be never defined.
Loading history...
177
            return [];
178
        }
179
180 12
        $createTableToken = $code[0][$lastMatchIndex - 1];
181 12
        $result = [];
182 12
        $offset = 0;
183
184 12
        while (true) {
185 12
            $pattern = (new SqlTokenizer('any CHECK()'))->tokenize();
186
187 12
            if (!$createTableToken->matches($pattern, $offset, $firstMatchIndex, $offset)) {
188 12
                break;
189
            }
190
191 3
            $checkSql = $createTableToken[$offset - 1]->getSql();
192 3
            $name = null;
193 3
            $pattern = (new SqlTokenizer('CONSTRAINT any'))->tokenize();
194
195 3
            if (isset($createTableToken[$firstMatchIndex - 2]) && $createTableToken->matches($pattern, $firstMatchIndex - 2)) {
196
                $name = $createTableToken[$firstMatchIndex - 1]->content;
197
            }
198
199 3
            $ck = new CheckConstraint();
200 3
            $ck->setName($name);
201 3
            $ck->setExpression($checkSql);
202
203 3
            $result[] = $ck;
204
        }
205
206 12
        return $result;
207
    }
208
209
    /**
210
     * {@inheritdoc}
211
     *
212
     * @throws NotSupportedException if this method is called.
213
     */
214 12
    protected function loadTableDefaultValues($tableName)
0 ignored issues
show
Unused Code introduced by
The parameter $tableName is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

214
    protected function loadTableDefaultValues(/** @scrutinizer ignore-unused */ $tableName)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
215
    {
216 12
        throw new NotSupportedException('SQLite does not support default value constraints.');
217
    }
218
219
    /**
220
     * Creates a query builder for the MySQL database.
221
     *
222
     * This method may be overridden by child classes to create a DBMS-specific query builder.
223
     *
224
     * @return QueryBuilder query builder instance
225
     */
226 50
    public function createQueryBuilder()
227
    {
228 50
        return new QueryBuilder($this->db);
0 ignored issues
show
Bug introduced by
It seems like $this->db can also be of type null; however, parameter $db of Yiisoft\Db\Sqlite\Query\...yBuilder::__construct() does only seem to accept Yiisoft\Db\Drivers\Connection, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

228
        return new QueryBuilder(/** @scrutinizer ignore-type */ $this->db);
Loading history...
229
    }
230
231
    /**
232
     * {@inheritdoc}
233
     *
234
     * @return ColumnSchemaBuilder column schema builder instance
235
     */
236 3
    public function createColumnSchemaBuilder($type, $length = null)
237
    {
238 3
        return new ColumnSchemaBuilder($type, $length);
239
    }
240
241
    /**
242
     * Collects the table column metadata.
243
     *
244
     * @param TableSchema $table the table metadata
245
     *
246
     * @return bool whether the table exists in the database
247
     */
248 77
    protected function findColumns($table): bool
249
    {
250 77
        $sql = 'PRAGMA table_info(' . $this->quoteSimpleTableName($table->name) . ')';
251 77
        $columns = $this->db->createCommand($sql)->queryAll();
252
253 77
        if (empty($columns)) {
254 10
            return false;
255
        }
256
257 75
        foreach ($columns as $info) {
258 75
            $column = $this->loadColumnSchema($info);
259 75
            $table->columns[$column->name] = $column;
260 75
            if ($column->isPrimaryKey) {
261 52
                $table->primaryKey[] = $column->name;
262
            }
263
        }
264
265 75
        if (count($table->primaryKey) === 1 && !strncasecmp($table->columns[$table->primaryKey[0]]->dbType, 'int', 3)) {
266 52
            $table->sequenceName = '';
267 52
            $table->columns[$table->primaryKey[0]]->autoIncrement = true;
268
        }
269
270 75
        return true;
271
    }
272
273
    /**
274
     * Collects the foreign key column details for the given table.
275
     *
276
     * @param TableSchema $table the table metadata
277
     */
278 75
    protected function findConstraints($table)
279
    {
280 75
        $sql = 'PRAGMA foreign_key_list(' . $this->quoteSimpleTableName($table->name) . ')';
281 75
        $keys = $this->db->createCommand($sql)->queryAll();
282
283 75
        foreach ($keys as $key) {
284 5
            $id = (int) $key['id'];
285 5
            if (!isset($table->foreignKeys[$id])) {
286 5
                $table->foreignKeys[$id] = [$key['table'], $key['from'] => $key['to']];
287
            } else {
288
                // composite FK
289 5
                $table->foreignKeys[$id][$key['from']] = $key['to'];
290
            }
291
        }
292 75
    }
293
294
    /**
295
     * Returns all unique indexes for the given table.
296
     *
297
     * Each array element is of the following structure:
298
     *
299
     * ```php
300
     * [
301
     *     'IndexName1' => ['col1' [, ...]],
302
     *     'IndexName2' => ['col2' [, ...]],
303
     * ]
304
     * ```
305
     *
306
     * @param TableSchema $table the table metadata
307
     *
308
     * @return array all unique indexes for the given table.
309
     */
310 1
    public function findUniqueIndexes($table)
311
    {
312 1
        $sql = 'PRAGMA index_list(' . $this->quoteSimpleTableName($table->name) . ')';
313 1
        $indexes = $this->db->createCommand($sql)->queryAll();
314 1
        $uniqueIndexes = [];
315
316 1
        foreach ($indexes as $index) {
317 1
            $indexName = $index['name'];
318 1
            $indexInfo = $this->db->createCommand(
319 1
                'PRAGMA index_info(' . $this->quoteValue($index['name']) . ')'
320 1
            )->queryAll();
321
322 1
            if ($index['unique']) {
323 1
                $uniqueIndexes[$indexName] = [];
324 1
                foreach ($indexInfo as $row) {
325 1
                    $uniqueIndexes[$indexName][] = $row['name'];
326
                }
327
            }
328
        }
329
330 1
        return $uniqueIndexes;
331
    }
332
333
    /**
334
     * Loads the column information into a {@see ColumnSchema} object.
335
     *
336
     * @param array $info column information
337
     *
338
     * @return ColumnSchema the column schema object
339
     */
340 75
    protected function loadColumnSchema($info): ColumnSchema
341
    {
342 75
        $column = $this->createColumnSchema();
343 75
        $column->name = $info['name'];
344 75
        $column->allowNull = !$info['notnull'];
345 75
        $column->isPrimaryKey = $info['pk'] != 0;
346 75
        $column->dbType = strtolower($info['type']);
347 75
        $column->unsigned = strpos($column->dbType, 'unsigned') !== false;
348 75
        $column->type = self::TYPE_STRING;
349
350 75
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->dbType, $matches)) {
351 75
            $type = strtolower($matches[1]);
352
353 75
            if (isset($this->typeMap[$type])) {
354 75
                $column->type = $this->typeMap[$type];
355
            }
356
357 75
            if (!empty($matches[2])) {
358 72
                $values = explode(',', $matches[2]);
359 72
                $column->size = $column->precision = (int) $values[0];
360 72
                if (isset($values[1])) {
361 25
                    $column->scale = (int) $values[1];
362
                }
363 72
                if ($column->size === 1 && ($type === 'tinyint' || $type === 'bit')) {
364 21
                    $column->type = 'boolean';
365 72
                } elseif ($type === 'bit') {
366
                    if ($column->size > 32) {
367
                        $column->type = 'bigint';
368
                    } elseif ($column->size === 32) {
369
                        $column->type = 'integer';
370
                    }
371
                }
372
            }
373
        }
374
375 75
        $column->phpType = $this->getColumnPhpType($column);
376
377 75
        if (!$column->isPrimaryKey) {
378 74
            if ($info['dflt_value'] === 'null' || $info['dflt_value'] === '' || $info['dflt_value'] === null) {
379 73
                $column->defaultValue = null;
380 60
            } elseif ($column->type === 'timestamp' && $info['dflt_value'] === 'CURRENT_TIMESTAMP') {
381 21
                $column->defaultValue = new Expression('CURRENT_TIMESTAMP');
382
            } else {
383 60
                $value = trim($info['dflt_value'], "'\"");
384 60
                $column->defaultValue = $column->phpTypecast($value);
385
            }
386
        }
387
388 75
        return $column;
389
    }
390
391
    /**
392
     * Sets the isolation level of the current transaction.
393
     *
394
     * @param string $level The transaction isolation level to use for this transaction.
395
     * This can be either {@see Transaction::READ_UNCOMMITTED} or {@see Transaction::SERIALIZABLE}.
396
     *
397
     * @throws NotSupportedException when unsupported isolation levels are used.
398
     * SQLite only supports SERIALIZABLE and READ UNCOMMITTED.
399
     *
400
     * {@see http://www.sqlite.org/pragma.html#pragma_read_uncommitted}
401
     */
402 3
    public function setTransactionIsolationLevel($level): void
403
    {
404
        switch ($level) {
405 3
            case Transaction::SERIALIZABLE:
406 1
                $this->db->createCommand('PRAGMA read_uncommitted = False;')->execute();
407 1
                break;
408 3
            case Transaction::READ_UNCOMMITTED:
409 3
                $this->db->createCommand('PRAGMA read_uncommitted = True;')->execute();
410 3
                break;
411
            default:
412
                throw new NotSupportedException(
413
                    get_class($this) . ' only supports transaction isolation levels READ UNCOMMITTED and SERIALIZABLE.'
414
                );
415
        }
416 3
    }
417
418
    /**
419
     * Returns table columns info.
420
     *
421
     * @param string $tableName table name
422
     *
423
     * @return array
424
     */
425 28
    private function loadTableColumnsInfo($tableName): array
426
    {
427 28
        $tableColumns = $this->db->createCommand(
428 28
            'PRAGMA TABLE_INFO (' . $this->quoteValue($tableName) . ')'
429 28
        )->queryAll();
430
431 28
        $tableColumns = $this->normalizePdoRowKeyCase($tableColumns, true);
432
433 28
        return ArrayHelper::index($tableColumns, 'cid');
434
    }
435
436
    /**
437
     * Loads multiple types of constraints and returns the specified ones.
438
     *
439
     * @param string $tableName table name.
440
     * @param string $returnType return type:
441
     * - primaryKey
442
     * - indexes
443
     * - uniques
444
     *
445
     * @return mixed constraints.
446
     */
447 52
    private function loadTableConstraints($tableName, $returnType)
448
    {
449 52
        $indexes = $this->db->createCommand('PRAGMA INDEX_LIST (' . $this->quoteValue($tableName) . ')')->queryAll();
450 52
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
451 52
        $tableColumns = null;
452
453 52
        if (!empty($indexes) && !isset($indexes[0]['origin'])) {
454
            /*
455
             * SQLite may not have an "origin" column in INDEX_LIST
456
             * See https://www.sqlite.org/src/info/2743846cdba572f6
457
             */
458
            $tableColumns = $this->loadTableColumnsInfo($tableName);
459
        }
460
461
        $result = [
462 52
            'primaryKey' => null,
463
            'indexes' => [],
464
            'uniques' => [],
465
        ];
466
467 52
        foreach ($indexes as $index) {
468 42
            $columns = $this->db->createCommand(
469 42
                'PRAGMA INDEX_INFO (' . $this->quoteValue($index['name']) . ')'
470 42
            )->queryAll();
471
472 42
            $columns = $this->normalizePdoRowKeyCase($columns, true);
473
474 42
            ArrayHelper::multisort($columns, 'seqno', SORT_ASC, SORT_NUMERIC);
475
476 42
            if ($tableColumns !== null) {
477
                // SQLite may not have an "origin" column in INDEX_LIST
478
                $index['origin'] = 'c';
479
                if (!empty($columns) && $tableColumns[$columns[0]['cid']]['pk'] > 0) {
480
                    $index['origin'] = 'pk';
481
                } elseif ($index['unique'] && $this->isSystemIdentifier($index['name'])) {
482
                    $index['origin'] = 'u';
483
                }
484
            }
485
486 42
            $ic = new IndexConstraint();
487
488 42
            $ic->setIsPrimary($index['origin'] === 'pk');
489 42
            $ic->setIsUnique((bool) $index['unique']);
490 42
            $ic->setName($index['name']);
491 42
            $ic->setColumnNames(ArrayHelper::getColumn($columns, 'name'));
492
493 42
            $result['indexes'][] = $ic;
494
495 42
            if ($index['origin'] === 'u') {
496 41
                $ct = new Constraint();
497
498 41
                $ct->setName($index['name']);
499 41
                $ct->setColumnNames(ArrayHelper::getColumn($columns, 'name'));
500
501 41
                $result['uniques'][] = $ct;
502 25
            } elseif ($index['origin'] === 'pk') {
503 24
                $ct = new Constraint();
504
505 24
                $ct->setColumnNames(ArrayHelper::getColumn($columns, 'name'));
506
507 24
                $result['primaryKey'] = $ct;
508
            }
509
        }
510
511 52
        if ($result['primaryKey'] === null) {
512
            /*
513
             * Additional check for PK in case of INTEGER PRIMARY KEY with ROWID
514
             * See https://www.sqlite.org/lang_createtable.html#primkeyconst
515
             */
516
517 28
            if ($tableColumns === null) {
518 28
                $tableColumns = $this->loadTableColumnsInfo($tableName);
519
            }
520
521 28
            foreach ($tableColumns as $tableColumn) {
522 28
                if ($tableColumn['pk'] > 0) {
523 18
                    $ct = new Constraint();
524 18
                    $ct->setColumnNames([$tableColumn['name']]);
525
526 18
                    $result['primaryKey'] = $ct;
527 18
                    break;
528
                }
529
            }
530
        }
531
532 52
        foreach ($result as $type => $data) {
533 52
            $this->setTableMetadata($tableName, $type, $data);
534
        }
535
536 52
        return $result[$returnType];
537
    }
538
539
    /**
540
     * Return whether the specified identifier is a SQLite system identifier.
541
     *
542
     * @param string $identifier
543
     *
544
     * @return bool
545
     *
546
     * {@see https://www.sqlite.org/src/artifact/74108007d286232f}
547
     */
548
    private function isSystemIdentifier($identifier): bool
549
    {
550
        return strncmp($identifier, 'sqlite_', 7) === 0;
551
    }
552
}
553