Passed
Push — master ( bc6df6...b7a806 )
by y
05:58
created

Schema::offsetUnset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 2
rs 10
cc 1
nc 1
nop 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
     * Partial definition for `T_AUTOINCREMENT`, use that in compositions instead of this.
55
     */
56
    protected 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 `T_CONST` values.
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
    /**
128
     * Maps native/annotated types to `T_CONST` names.
129
     * This is used when generating migrations on the command-line.
130
     */
131
    const PHP_TYPE_NAMES = [
132
        'bool' => 'T_BOOL',
133
        'double' => 'T_BLOB',
134
        'float' => 'T_FLOAT',
135
        'int' => 'T_INT',
136
        'string' => 'T_STRING',
137
        'String' => 'T_TEXT',
138
        'STRING' => 'T_BLOB',
139
    ];
140
141
    /**
142
     * Driver-specific schema phrases.
143
     */
144
    protected const COLUMN_DEFINITIONS = [
145
        'mysql' => [
146
            self::I_AUTOINCREMENT => 'PRIMARY KEY AUTO_INCREMENT',
147
            self::I_PRIMARY => 'PRIMARY KEY',
148
            self::I_UNIQUE => 'UNIQUE',
149
            self::T_BLOB => 'LONGBLOB NULL DEFAULT NULL',
150
            self::T_BOOL => 'BOOLEAN NULL DEFAULT NULL',
151
            self::T_FLOAT => 'DOUBLE PRECISION NULL DEFAULT NULL',
152
            self::T_INT => 'BIGINT NULL DEFAULT NULL',
153
            self::T_STRING => 'VARCHAR(255) NULL DEFAULT NULL',
154
            self::T_TEXT => 'TEXT NULL DEFAULT NULL',
155
            self::T_BLOB_STRICT => 'LONGBLOB NOT NULL',
156
            self::T_BOOL_STRICT => 'BOOLEAN NOT NULL',
157
            self::T_FLOAT_STRICT => 'DOUBLE PRECISION NOT NULL',
158
            self::T_INT_STRICT => 'BIGINT NOT NULL',
159
            self::T_STRING_STRICT => 'VARCHAR(255) NOT NULL',
160
            self::T_TEXT_STRICT => 'TEXT NOT NULL',
161
        ],
162
        'sqlite' => [
163
            self::I_AUTOINCREMENT => 'PRIMARY KEY AUTOINCREMENT',
164
            self::I_PRIMARY => 'PRIMARY KEY',
165
            self::I_UNIQUE => 'UNIQUE',
166
            self::T_BLOB => 'BLOB DEFAULT NULL',
167
            self::T_BOOL => 'BOOLEAN DEFAULT NULL',
168
            self::T_FLOAT => 'REAL DEFAULT NULL',
169
            self::T_INT => 'INTEGER DEFAULT NULL',
170
            self::T_STRING => 'TEXT DEFAULT NULL',
171
            self::T_TEXT => 'TEXT DEFAULT NULL',
172
            self::T_BLOB_STRICT => 'BLOB NOT NULL',
173
            self::T_BOOL_STRICT => 'BOOLEAN NOT NULL',
174
            self::T_FLOAT_STRICT => 'REAL NOT NULL',
175
            self::T_INT_STRICT => 'INTEGER NOT NULL',
176
            self::T_STRING_STRICT => 'TEXT NOT NULL',
177
            self::T_TEXT_STRICT => 'TEXT NOT NULL',
178
        ]
179
    ];
180
181
    /**
182
     * @var int[]
183
     */
184
    protected $colDefs;
185
186
    /**
187
     * @var DB
188
     */
189
    protected $db;
190
191
    /**
192
     * @var Table[]
193
     */
194
    protected $tables = [];
195
196
    /**
197
     * @param DB $db
198
     */
199
    public function __construct (DB $db) {
200
        $this->db = $db;
201
        $this->colDefs ??= self::COLUMN_DEFINITIONS[$db->getDriver()];
202
    }
203
204
    /**
205
     * `ALTER TABLE $table ADD COLUMN $column ...`
206
     *
207
     * @param string $table
208
     * @param string $column
209
     * @param int $type
210
     * @return $this
211
     */
212
    public function addColumn (string $table, string $column, int $type = self::T_STRING) {
213
        $type = $this->colDefs[$type & self::T_MASK];
214
        $this->db->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$type}");
215
        unset($this->tables[$table]);
216
        return $this;
217
    }
218
219
    /**
220
     * `CREATE TABLE $table ...`
221
     *
222
     * At least one column must be given.
223
     *
224
     * `$constraints` is a multidimensional array of table-level constraints.
225
     * - `TABLE_PRIMARY => [col, col, col]`
226
     *      - String list of columns composing the primary key.
227
     *      - Not needed for single-column primary keys. Use `I_PRIMARY` or `T_AUTOINCREMENT` for that.
228
     * - `TABLE_UNIQUE => [ [col, col, col] , ... ]`
229
     *      - One or more string lists of columns, each grouping composing a unique key together.
230
     * - `TABLE_FOREIGN => [ col => <External Column> ]`
231
     *      - Associative columns that are each foreign keys to a {@link Column} instance.
232
     *
233
     * @param string $table
234
     * @param int[] $columns `[ name => <I_CONST> | <T_CONST> ]`
235
     * @param array[] $constraints `[ <TABLE_CONST> => spec ]`
236
     * @return $this
237
     */
238
    public function createTable (string $table, array $columns, array $constraints = []) {
239
        $defs = $this->toColumnDefinitions($columns);
240
241
        /** @var string[] $pk */
242
        if ($pk = $constraints[self::TABLE_PRIMARY] ?? []) {
243
            $defs[] = $this->toPrimaryKeyConstraint($table, $pk);
244
        }
245
246
        /** @var string[] $unique */
247
        foreach ($constraints[self::TABLE_UNIQUE] ?? [] as $unique) {
248
            $defs[] = $this->toUniqueKeyConstraint($table, $unique);
249
        }
250
251
        /** @var string $local */
252
        /** @var Column $foreign */
253
        foreach ($constraints[self::TABLE_FOREIGN] ?? [] as $local => $foreign) {
254
            $defs[] = $this->toForeignKeyConstraint($table, $local, $foreign);
255
        }
256
257
        $sql = sprintf(
258
            "CREATE TABLE %s (%s)",
259
            $table,
260
            implode(', ', $defs)
261
        );
262
263
        $this->db->exec($sql);
264
        return $this;
265
    }
266
267
    /**
268
     * `ALTER TABLE $table DROP COLUMN $column`
269
     *
270
     * @param string $table
271
     * @param string $column
272
     * @return $this
273
     */
274
    public function dropColumn (string $table, string $column) {
275
        $this->db->exec("ALTER TABLE {$table} DROP COLUMN {$column}");
276
        unset($this->tables[$table]);
277
        return $this;
278
    }
279
280
    /**
281
     * `DROP TABLE IF EXISTS $table`
282
     *
283
     * @param string $table
284
     */
285
    public function dropTable (string $table): void {
286
        $this->db->exec("DROP TABLE IF EXISTS {$table}");
287
        unset($this->tables[$table]);
288
    }
289
290
    /**
291
     * @return DB
292
     */
293
    public function getDb () {
294
        return $this->db;
295
    }
296
297
    /**
298
     * @param string $name
299
     * @return null|Table
300
     */
301
    public function getTable (string $name) {
302
        if (!isset($this->tables[$name])) {
303
            if ($this->db->isSQLite()) {
304
                $info = $this->db->query("PRAGMA table_info({$name})")->fetchAll();
305
                $cols = array_column($info, 'name');
306
            }
307
            else {
308
                $cols = $this->db->query(
309
                    "SELECT column_name FROM information_schema.tables WHERE table_name = \"{$name}\""
310
                )->fetchAll(DB::FETCH_COLUMN);
311
            }
312
            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...
313
                return null;
314
            }
315
            $this->tables[$name] = Table::factory($this->db, $name, $cols);
316
        }
317
        return $this->tables[$name];
318
    }
319
320
    /**
321
     * Whether a table exists.
322
     *
323
     * @param string $table
324
     * @return bool
325
     */
326
    final public function offsetExists ($table): bool {
327
        return (bool)$this->offsetGet($table);
328
    }
329
330
    /**
331
     * Returns a table by name.
332
     *
333
     * @param string $table
334
     * @return null|Table
335
     */
336
    public function offsetGet ($table) {
337
        return $this->getTable($table);
338
    }
339
340
    /**
341
     * @param $offset
342
     * @param $value
343
     * @throws LogicException
344
     */
345
    final public function offsetSet ($offset, $value) {
346
        throw new LogicException('The schema cannot be altered this way.');
347
    }
348
349
    /**
350
     * @param $offset
351
     * @throws LogicException
352
     */
353
    final public function offsetUnset ($offset) {
354
        throw new LogicException('The schema cannot be altered this way.');
355
    }
356
357
    /**
358
     * `ALTER TABLE $table RENAME COLUMN $oldName TO $newName`
359
     *
360
     * @param string $table
361
     * @param string $oldName
362
     * @param string $newName
363
     * @return $this
364
     */
365
    public function renameColumn (string $table, string $oldName, string $newName) {
366
        $this->db->exec("ALTER TABLE {$table} RENAME COLUMN {$oldName} TO {$newName}");
367
        unset($this->tables[$table]);
368
        return $this;
369
    }
370
371
    /**
372
     * `ALTER TABLE $oldName RENAME TO $newName`
373
     *
374
     * @param string $oldName
375
     * @param string $newName
376
     * @return $this
377
     */
378
    public function renameTable (string $oldName, string $newName) {
379
        $this->db->exec("ALTER TABLE {$oldName} RENAME TO {$newName}");
380
        unset($this->tables[$oldName]);
381
        return $this;
382
    }
383
384
    /**
385
     * Sorts according to index priority, storage size/complexity, and name.
386
     *
387
     * @param int[] $types
388
     * @return int[]
389
     */
390
    protected function sortColumns (array $types): array {
391
        uksort($types, function(string $a, string $b) use ($types) {
392
            // descending index priority, increasing size, name
393
            return $types[$b] <=> $types[$a] ?: $a <=> $b;
394
        });
395
        return $types;
396
    }
397
398
    /**
399
     * @param int[] $columns `[ name => <I_CONST> | <T_CONST> ]`
400
     * @return string[]
401
     */
402
    protected function toColumnDefinitions (array $columns): array {
403
        assert(count($columns) > 0);
404
        $columns = $this->sortColumns($columns);
405
        $defs = [];
406
407
        /**
408
         * @var string $name
409
         * @var int $type
410
         */
411
        foreach ($columns as $name => $type) {
412
            $defs[$name] = sprintf("%s %s", $name, $this->colDefs[$type & self::T_MASK]);
413
            if ($indexSql = $type & self::I_MASK) {
414
                $defs[$name] .= " {$this->colDefs[$indexSql]}";
415
            }
416
        }
417
418
        return $defs;
419
    }
420
421
    /**
422
     * @param string $table
423
     * @param string $local
424
     * @param Column $foreign
425
     * @return string
426
     */
427
    protected function toForeignKeyConstraint (string $table, string $local, Column $foreign): string {
428
        return sprintf(
429
            'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s) ON DELETE CASCADE',
430
            $this->toForeignKeyConstraint_name($table, $local),
431
            $local,
432
            $foreign->getQualifier(),
433
            $foreign->getName()
434
        );
435
    }
436
437
    /**
438
     * `FK_TABLE__COLUMN__COLUMN__COLUMN`
439
     *
440
     * @param string $table
441
     * @param string $column
442
     * @return string
443
     */
444
    protected function toForeignKeyConstraint_name (string $table, string $column): string {
445
        return 'FK_' . $table . '__' . $column;
446
    }
447
448
    /**
449
     * @param string $table
450
     * @param string[] $columns
451
     * @return string
452
     */
453
    protected function toPrimaryKeyConstraint (string $table, array $columns): string {
454
        return sprintf(
455
            'CONSTRAINT %s PRIMARY KEY (%s)',
456
            $this->toPrimaryKeyConstraint_name($table, $columns),
457
            implode(',', $columns)
458
        );
459
    }
460
461
    /**
462
     * `PK_TABLE__COLUMN__COLUMN__COLUMN`
463
     *
464
     * @param string $table
465
     * @param string[] $columns
466
     * @return string
467
     */
468
    protected function toPrimaryKeyConstraint_name (string $table, array $columns): string {
469
        sort($columns, SORT_STRING);
470
        return 'PK_' . $table . '__' . implode('__', $columns);
471
    }
472
473
    /**
474
     * @param string $table
475
     * @param string[] $columns
476
     * @return string
477
     */
478
    protected function toUniqueKeyConstraint (string $table, array $columns): string {
479
        return sprintf(
480
            'CONSTRAINT %s UNIQUE (%s)',
481
            $this->toUniqueKeyConstraint_name($table, $columns),
482
            implode(',', $columns)
483
        );
484
    }
485
486
    /**
487
     * `UQ_TABLE__COLUMN__COLUMN__COLUMN`
488
     *
489
     * @param string $table
490
     * @param string[] $columns
491
     * @return string
492
     */
493
    protected function toUniqueKeyConstraint_name (string $table, array $columns): string {
494
        sort($columns, SORT_STRING);
495
        return 'UQ_' . $table . '__' . implode('__', $columns);
496
    }
497
}