Passed
Push — master ( f39217...bc6df6 )
by y
02:20
created

Schema   A

Complexity

Total Complexity 33

Size/Duplication

Total Lines 499
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 33
eloc 161
c 1
b 0
f 0
dl 0
loc 499
rs 9.76

23 Methods

Rating   Name   Duplication   Size   Complexity  
A dropTable() 0 3 1
A offsetUnset() 0 2 1
A toUniqueKeyConstraint() 0 5 1
A createJunctionTable() 0 5 1
A offsetExists() 0 2 1
A dropColumn() 0 4 1
A toColumnDefinitions() 0 17 3
A getTable() 0 17 4
A toUniqueKeyConstraint_name() 0 3 1
A offsetSet() 0 2 1
A createRecordTable() 0 8 2
A offsetGet() 0 2 1
A toPrimaryKeyConstraint_name() 0 3 1
A renameColumn() 0 4 1
A renameTable() 0 4 1
A createTable() 0 27 4
A toForeignKeyConstraint() 0 7 1
A createEavTable() 0 9 1
A sortColumns() 0 6 2
A __construct() 0 3 1
A addColumn() 0 5 1
A toPrimaryKeyConstraint() 0 5 1
A toForeignKeyConstraint_name() 0 2 1
1
<?php
2
3
namespace Helix\DB;
4
5
use ArrayAccess;
6
use Helix\DB;
7
use LogicException;
8
9
/**
10
 * Schema control and metadata.
11
 *
12
 * The column definition constants are two bytes each, used in bitwise composition.
13
 * - The high-byte (`<I_CONST>`) is used for the specific index type.
14
 *      - Descending index priority. For example, `I_PRIMARY` is `0x8000` (highest bit)
15
 * - The low-byte (`<T_CONST>`) is used for the specific storage type.
16
 *      - Inverse size complexity. For example, `BOOL` is `0x0080`, and `T_BLOB` is `0x0002`
17
 *      - The final bit `0x0001` flags nullability.
18
 *      - Bit `0x0010` is reserved for future use (probably `DateTime`).
19
 * - The literal values may change in the future, do not hard code them.
20
 * - The values may expand to use a total of 4 or 8 bytes to accommodate more stuff.
21
 *
22
 * @method static static factory(DB $db)
23
 */
24
class Schema implements ArrayAccess {
25
26
    use FactoryTrait;
27
28
    /**
29
     * `<TABLE_CONST>`: Multi-column primary key.
30
     */
31
    const TABLE_PRIMARY = 0;
32
33
    /**
34
     * `<TABLE_CONST>`: Associative foreign keys.
35
     */
36
    const TABLE_FOREIGN = 1;
37
38
    /**
39
     * `<TABLE_CONST>`: Groups of columns are unique together.
40
     */
41
    const TABLE_UNIQUE = 2;
42
43
    /**
44
     * Higher byte mask (column index type).
45
     */
46
    protected const I_MASK = 0xFF00;
47
48
    /**
49
     * `<I_CONST>`: Column is the primary key.
50
     */
51
    const I_PRIMARY = 0x8000;
52
53
    /**
54
     * `<I_CONST>`: Column is the primary key and auto-increments.
55
     */
56
    const I_AUTOINCREMENT = 0x4000 | self::I_PRIMARY;
57
58
    /**
59
     * `<I_CONST>`: Column is unique.
60
     */
61
    const I_UNIQUE = 0x2000;
62
63
    /**
64
     * Lower-byte mask (column storage type).
65
     */
66
    protected const T_MASK = 0x00FF;
67
68
    /**
69
     * `<T_CONST>`: Column is the primary key and auto-increments (8-byte signed integer).
70
     */
71
    const T_AUTOINCREMENT = self::I_AUTOINCREMENT | self::T_INT_STRICT;
72
73
    /**
74
     * Flags whether a type is `NOT NULL`
75
     */
76
    protected const T_STRICT = 0x0001;
77
78
    /**
79
     * `<T_CONST>`: Boolean analog (numeric).
80
     */
81
    const T_BOOL = 0x0080;
82
    const T_BOOL_STRICT = self::T_BOOL | self::T_STRICT;
83
84
    /**
85
     * `<T_CONST>`: 8-byte signed integer.
86
     */
87
    const T_INT = 0x0040;
88
    const T_INT_STRICT = self::T_INT | self::T_STRICT;
89
90
    /**
91
     * `<T_CONST>`: 8-byte IEEE floating point number.
92
     */
93
    const T_FLOAT = 0x0020;
94
    const T_FLOAT_STRICT = self::T_FLOAT | self::T_STRICT;
95
96
    /**
97
     * `<T_CONST>`: UTF-8 up to 255 bytes.
98
     */
99
    const T_STRING = 0x0008;
100
    const T_STRING_STRICT = self::T_STRING | self::T_STRICT;
101
102
    /**
103
     * `<T_CONST>`: UTF-8 up to 64KiB.
104
     */
105
    const T_TEXT = 0x0004;
106
    const T_TEXT_STRICT = self::T_TEXT | self::T_STRICT;
107
108
    /**
109
     * `<T_CONST>`: Arbitrary binary data up to 4GiB.
110
     */
111
    const T_BLOB = 0x0002;
112
    const T_BLOB_STRICT = self::T_BLOB | self::T_STRICT;
113
114
    /**
115
     * Maps native/annotated types to storage types.
116
     */
117
    protected const PHP_TYPES = [
118
        'bool' => self::T_BOOL,
119
        'double' => self::T_FLOAT,
120
        'float' => self::T_FLOAT,
121
        'int' => self::T_INT,
122
        'string' => self::T_STRING,
123
        'String' => self::T_TEXT,
124
        'STRING' => self::T_BLOB
125
    ];
126
    /**
127
     * Driver-specific schema phrases.
128
     */
129
    protected const COLUMN_DEFINITIONS = [
130
        'mysql' => [
131
            self::I_AUTOINCREMENT => 'PRIMARY KEY AUTO_INCREMENT',
132
            self::I_PRIMARY => 'PRIMARY KEY',
133
            self::I_UNIQUE => 'UNIQUE',
134
            self::T_BLOB => 'LONGBLOB NULL DEFAULT NULL',
135
            self::T_BOOL => 'BOOLEAN NULL DEFAULT NULL',
136
            self::T_FLOAT => 'DOUBLE PRECISION NULL DEFAULT NULL',
137
            self::T_INT => 'BIGINT NULL DEFAULT NULL',
138
            self::T_STRING => 'VARCHAR(255) NULL DEFAULT NULL',
139
            self::T_TEXT => 'TEXT NULL DEFAULT NULL',
140
            self::T_BLOB_STRICT => 'LONGBLOB NOT NULL',
141
            self::T_BOOL_STRICT => 'BOOLEAN NOT NULL',
142
            self::T_FLOAT_STRICT => 'DOUBLE PRECISION NOT NULL',
143
            self::T_INT_STRICT => 'BIGINT NOT NULL',
144
            self::T_STRING_STRICT => 'VARCHAR(255) NOT NULL',
145
            self::T_TEXT_STRICT => 'TEXT NOT NULL',
146
        ],
147
        'sqlite' => [
148
            self::I_AUTOINCREMENT => 'PRIMARY KEY AUTOINCREMENT',
149
            self::I_PRIMARY => 'PRIMARY KEY',
150
            self::I_UNIQUE => 'UNIQUE',
151
            self::T_BLOB => 'BLOB DEFAULT NULL',
152
            self::T_BOOL => 'BOOLEAN DEFAULT NULL',
153
            self::T_FLOAT => 'REAL DEFAULT NULL',
154
            self::T_INT => 'INTEGER DEFAULT NULL',
155
            self::T_STRING => 'TEXT DEFAULT NULL',
156
            self::T_TEXT => 'TEXT DEFAULT NULL',
157
            self::T_BLOB_STRICT => 'BLOB NOT NULL',
158
            self::T_BOOL_STRICT => 'BOOLEAN NOT NULL',
159
            self::T_FLOAT_STRICT => 'REAL NOT NULL',
160
            self::T_INT_STRICT => 'INTEGER NOT NULL',
161
            self::T_STRING_STRICT => 'TEXT NOT NULL',
162
            self::T_TEXT_STRICT => 'TEXT NOT NULL',
163
        ]
164
    ];
165
166
    /**
167
     * @var int[]
168
     */
169
    protected $colDefs;
170
171
    /**
172
     * @var DB
173
     */
174
    protected $db;
175
176
    /**
177
     * @var Table[]
178
     */
179
    protected $tables = [];
180
181
    /**
182
     * @param DB $db
183
     */
184
    public function __construct (DB $db) {
185
        $this->db = $db;
186
        $this->colDefs ??= self::COLUMN_DEFINITIONS[$db->getDriver()];
187
    }
188
189
    /**
190
     * `ALTER TABLE $table ADD COLUMN $column ...`
191
     *
192
     * @param string $table
193
     * @param string $column
194
     * @param int $type
195
     * @return $this
196
     */
197
    public function addColumn (string $table, string $column, int $type = self::T_STRING) {
198
        $type = $this->colDefs[$type & self::T_MASK];
199
        $this->db->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$type}");
200
        unset($this->tables[$table]);
201
        return $this;
202
    }
203
204
    /**
205
     * Creates the underlying storage for an {@link EAV}
206
     *
207
     * @param Record $record From {@link DB::getRecord()}
208
     * @param string $property The EAV property name in the record.
209
     * @return $this
210
     */
211
    public function createEavTable (Record $record, string $property) {
212
        $eav = $record->getEav($property);
213
        return $this->createTable($eav, [
214
            'entity' => self::T_INT_STRICT,
215
            'attribute' => self::T_STRING_STRICT,
216
            'value' => self::PHP_TYPES[$eav->getValueType()],
217
        ], [
218
            self::TABLE_PRIMARY => ['entity', 'attribute'],
219
            self::TABLE_FOREIGN => ['entity' => $record['id']]
220
        ]);
221
    }
222
223
    /**
224
     * Creates the underlying storage for a {@link Junction}
225
     *
226
     * @param Junction $junction From {@link DB::getJunction()}
227
     * @return $this
228
     */
229
    public function createJunctionTable (Junction $junction) {
230
        $records = $junction->getRecords();
231
        return $this->createTable($junction, array_map(fn() => self::T_INT_STRICT, $records), [
232
            self::TABLE_PRIMARY => array_keys($records),
233
            self::TABLE_FOREIGN => array_map(fn(Record $record) => $record['id'], $records)
234
        ]);
235
    }
236
237
    /**
238
     * Creates the underlying storage for a {@link Record}
239
     *
240
     * @param Record $record From {@link DB::getRecord()}
241
     * @param array[] $constraints See {@link Schema::createTable()}
242
     * @return $this
243
     */
244
    public function createRecordTable (Record $record, array $constraints = []) {
245
        $columns = $record->getTypes();
246
        array_walk($columns, function(string &$type, string $property) use ($record) {
247
            $type = self::PHP_TYPES[$type] | ($record->isNullable($property) ? 0 : self::T_STRICT);
248
        });
249
        $columns['id'] = self::T_AUTOINCREMENT; // force to autoincrement
250
        unset($constraints[self::TABLE_PRIMARY]); // `id` is the sole pk
251
        return $this->createTable($record, $columns, $constraints);
252
    }
253
254
    /**
255
     * `CREATE TABLE $table ...`
256
     *
257
     * At least one column must be given.
258
     *
259
     * `$constraints` is a multidimensional array of table-level constraints.
260
     * - `C_PRIMARY => [col, col, col]`
261
     *      - A list of columns composing the primary key.
262
     * - `C_UNIQUE => [ [col, col, col] , ... ]`
263
     *      - One or more column groups, each group composing a unique key.
264
     * - `C_FOREIGN => [ local column => <Column> ]`
265
     *      - Associative local columns that are each foreign keys to a {@link Column}
266
     *
267
     * @param string $table
268
     * @param int[] $columns `[ name => <I_CONST> | <T_CONST> ]`
269
     * @param array[] $constraints `[ <TABLE_CONST> => table constraint spec ]`
270
     * @return $this
271
     */
272
    public function createTable (string $table, array $columns, array $constraints = []) {
273
        $defs = $this->toColumnDefinitions($columns);
274
275
        /** @var string[] $pk */
276
        if ($pk = $constraints[self::TABLE_PRIMARY] ?? []) {
277
            $defs[] = $this->toPrimaryKeyConstraint($table, $pk);
278
        }
279
280
        /** @var string[] $unique */
281
        foreach ($constraints[self::TABLE_UNIQUE] ?? [] as $unique) {
282
            $defs[] = $this->toUniqueKeyConstraint($table, $unique);
283
        }
284
285
        /** @var string $local */
286
        /** @var Column $foreign */
287
        foreach ($constraints[self::TABLE_FOREIGN] ?? [] as $local => $foreign) {
288
            $defs[] = $this->toForeignKeyConstraint($table, $local, $foreign);
289
        }
290
291
        $sql = sprintf(
292
            "CREATE TABLE %s (%s)",
293
            $table,
294
            implode(', ', $defs)
295
        );
296
297
        $this->db->exec($sql);
298
        return $this;
299
    }
300
301
    /**
302
     * `ALTER TABLE $table DROP COLUMN $column`
303
     *
304
     * @param string $table
305
     * @param string $column
306
     * @return $this
307
     */
308
    public function dropColumn (string $table, string $column) {
309
        $this->db->exec("ALTER TABLE {$table} DROP COLUMN {$column}");
310
        unset($this->tables[$table]);
311
        return $this;
312
    }
313
314
    /**
315
     * `DROP TABLE IF EXISTS $table`
316
     *
317
     * @param string $table
318
     */
319
    public function dropTable (string $table): void {
320
        $this->db->exec("DROP TABLE IF EXISTS {$table}");
321
        unset($this->tables[$table]);
322
    }
323
324
    /**
325
     * @param string $name
326
     * @return null|Table
327
     */
328
    public function getTable (string $name) {
329
        if (!isset($this->tables[$name])) {
330
            if ($this->db->isSQLite()) {
331
                $info = $this->db->query("PRAGMA table_info({$name})")->fetchAll();
332
                $cols = array_column($info, 'name');
333
            }
334
            else {
335
                $cols = $this->db->query(
336
                    "SELECT column_name FROM information_schema.tables WHERE table_name = \"{$name}\""
337
                )->fetchAll(DB::FETCH_COLUMN);
338
            }
339
            if (!$cols) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cols of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
340
                return null;
341
            }
342
            $this->tables[$name] = Table::factory($this->db, $name, $cols);
343
        }
344
        return $this->tables[$name];
345
    }
346
347
    /**
348
     * Whether a table exists.
349
     *
350
     * @param string $table
351
     * @return bool
352
     */
353
    final public function offsetExists ($table): bool {
354
        return (bool)$this->offsetGet($table);
355
    }
356
357
    /**
358
     * Returns a table by name.
359
     *
360
     * @param string $table
361
     * @return null|Table
362
     */
363
    public function offsetGet ($table) {
364
        return $this->getTable($table);
365
    }
366
367
    /**
368
     * @param $offset
369
     * @param $value
370
     * @throws LogicException
371
     */
372
    final public function offsetSet ($offset, $value) {
373
        throw new LogicException('The schema cannot be altered this way.');
374
    }
375
376
    /**
377
     * @param $offset
378
     * @throws LogicException
379
     */
380
    final public function offsetUnset ($offset) {
381
        throw new LogicException('The schema cannot be altered this way.');
382
    }
383
384
    /**
385
     * `ALTER TABLE $table RENAME COLUMN $oldName TO $newName`
386
     *
387
     * @param string $table
388
     * @param string $oldName
389
     * @param string $newName
390
     * @return $this
391
     */
392
    public function renameColumn (string $table, string $oldName, string $newName) {
393
        $this->db->exec("ALTER TABLE {$table} RENAME COLUMN {$oldName} TO {$newName}");
394
        unset($this->tables[$table]);
395
        return $this;
396
    }
397
398
    /**
399
     * `ALTER TABLE $oldName RENAME TO $newName`
400
     *
401
     * @param string $oldName
402
     * @param string $newName
403
     * @return $this
404
     */
405
    public function renameTable (string $oldName, string $newName) {
406
        $this->db->exec("ALTER TABLE {$oldName} RENAME TO {$newName}");
407
        unset($this->tables[$oldName]);
408
        return $this;
409
    }
410
411
    /**
412
     * Sorts according to index priority, storage size/complexity, and name.
413
     *
414
     * @param int[] $types
415
     * @return int[]
416
     */
417
    protected function sortColumns (array $types): array {
418
        uksort($types, function(string $a, string $b) use ($types) {
419
            // descending index priority, increasing size, name
420
            return $types[$b] <=> $types[$a] ?: $a <=> $b;
421
        });
422
        return $types;
423
    }
424
425
    /**
426
     * @param int[] $columns `[ name => <I_CONST> | <T_CONST> ]`
427
     * @return string[]
428
     */
429
    protected function toColumnDefinitions (array $columns): array {
430
        assert(count($columns) > 0);
431
        $columns = $this->sortColumns($columns);
432
        $defs = [];
433
434
        /**
435
         * @var string $name
436
         * @var int $type
437
         */
438
        foreach ($columns as $name => $type) {
439
            $defs[$name] = sprintf("%s %s", $name, $this->colDefs[$type & self::T_MASK]);
440
            if ($indexSql = $type & self::I_MASK) {
441
                $defs[$name] .= " {$this->colDefs[$indexSql]}";
442
            }
443
        }
444
445
        return $defs;
446
    }
447
448
    /**
449
     * @param string $table
450
     * @param string $local
451
     * @param Column $foreign
452
     * @return string
453
     */
454
    protected function toForeignKeyConstraint (string $table, string $local, Column $foreign): string {
455
        return sprintf(
456
            'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s) ON DELETE CASCADE',
457
            $this->toForeignKeyConstraint_name($table, $local),
458
            $local,
459
            $foreign->getQualifier(),
460
            $foreign->getName()
461
        );
462
    }
463
464
    /**
465
     * `FK_TABLE__COLUMN__COLUMN__COLUMN`
466
     *
467
     * @param string $table
468
     * @param string $column
469
     * @return string
470
     */
471
    protected function toForeignKeyConstraint_name (string $table, string $column): string {
472
        return 'FK_' . $table . '__' . $column;
473
    }
474
475
    /**
476
     * @param string $table
477
     * @param string[] $columns
478
     * @return string
479
     */
480
    protected function toPrimaryKeyConstraint (string $table, array $columns): string {
481
        return sprintf(
482
            'CONSTRAINT %s PRIMARY KEY (%s)',
483
            $this->toPrimaryKeyConstraint_name($table, $columns),
484
            implode(',', $columns)
485
        );
486
    }
487
488
    /**
489
     * `PK_TABLE__COLUMN__COLUMN__COLUMN`
490
     *
491
     * @param string $table
492
     * @param string[] $columns
493
     * @return string
494
     */
495
    protected function toPrimaryKeyConstraint_name (string $table, array $columns): string {
496
        sort($columns, SORT_STRING);
497
        return 'PK_' . $table . '__' . implode('__', $columns);
498
    }
499
500
    /**
501
     * @param string $table
502
     * @param string[] $columns
503
     * @return string
504
     */
505
    protected function toUniqueKeyConstraint (string $table, array $columns): string {
506
        return sprintf(
507
            'CONSTRAINT %s UNIQUE (%s)',
508
            $this->toUniqueKeyConstraint_name($table, $columns),
509
            implode(',', $columns)
510
        );
511
    }
512
513
    /**
514
     * `UQ_TABLE__COLUMN__COLUMN__COLUMN`
515
     *
516
     * @param string $table
517
     * @param string[] $columns
518
     * @return string
519
     */
520
    protected function toUniqueKeyConstraint_name (string $table, array $columns): string {
521
        sort($columns, SORT_STRING);
522
        return 'UQ_' . $table . '__' . implode('__', $columns);
523
    }
524
}