Passed
Push — master ( f45573...49b932 )
by y
02:08 queued 12s
created

Schema::getForeignKeyName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 2
dl 0
loc 3
rs 10
c 0
b 0
f 0
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 primary index type.
14
 * - The low-byte (`<T_CONST>`) is used for the specific storage type.
15
 *      - The final bit `0x01` flags `NOT NULL`
16
 * - The literal values may change in the future, do not hard code them.
17
 * - The values may expand to use a total of 4 or 8 bytes to accommodate more stuff.
18
 *
19
 * Definition constants are never returned by this class' methods. The methods can only receive them.
20
 *
21
 * @method static static factory(DB $db)
22
 */
23
class Schema implements ArrayAccess
24
{
25
26
    use FactoryTrait;
27
28
    /**
29
     * Higher byte mask (column index type).
30
     */
31
    protected const I_MASK = 0xff00;
32
33
    /**
34
     * Partial definition for `T_AUTOINCREMENT`, use that instead.
35
     */
36
    protected const I_AUTOINCREMENT = self::I_PRIMARY | 0x0100; // 0xff00
37
38
    /**
39
     * `<I_CONST>`: One or more columns compose the primary key.
40
     */
41
    const I_PRIMARY = 0xfe00;
42
43
    /**
44
     * Lower-byte mask (column storage type).
45
     */
46
    protected const T_MASK = 0xff;
47
48
    /**
49
     * `<T_CONST>`: Column is the primary key and auto-increments (8-byte signed integer).
50
     */
51
    const T_AUTOINCREMENT = self::I_AUTOINCREMENT | self::T_INT;
52
53
    /**
54
     * Flags whether a type is `NOT NULL`
55
     */
56
    protected const T_STRICT = 0x01;
57
58
    /**
59
     * `<T_CONST>`: Boolean analog (numeric).
60
     */
61
    const T_BOOL = 0xff;
62
    const T_BOOL_NULL = 0xfe;
63
64
    /**
65
     * `<T_CONST>`: 8-byte signed integer.
66
     */
67
    const T_INT = 0xfd;
68
    const T_INT_NULL = 0xfc;
69
70
    /**
71
     * `<T_CONST>`: 8-byte IEEE floating point number.
72
     */
73
    const T_FLOAT = 0xfb;
74
    const T_FLOAT_NULL = 0xfa;
75
76
    /**
77
     * `<T_CONST>`: Native `DATETIME` type, stored as `YYYY-MM-DD hh:mm:ss` UTC.
78
     */
79
    const T_DATETIME = 0xf9;
80
    const T_DATETIME_NULL = 0xf8;
81
    const DATETIME_FORMAT = 'Y-m-d H:i:s';
82
83
    /**
84
     * `<T_CONST>`: UTF-8 up to 255 bytes.
85
     */
86
    const T_STRING = 0xf7;
87
    const T_STRING_NULL = 0xf6;
88
89
    /**
90
     * `<T_CONST>`: UTF-8 up to 64KiB.
91
     */
92
    const T_TEXT = 0x05;
93
    const T_TEXT_NULL = 0x04;
94
95
    /**
96
     * `<T_CONST>`: Arbitrary binary data up to 4GiB.
97
     */
98
    const T_BLOB = 0x03;
99
    const T_BLOB_NULL = 0x02;
100
101
    /**
102
     * Maps storage types to `T_CONST` names.
103
     *
104
     * Resolved storage types in {@link Record} are keys here.
105
     *
106
     * This is also used when generating migrations on the command-line.
107
     */
108
    const T_CONST_NAMES = [
109
        'bool' => 'T_BOOL',
110
        'DateTime' => 'T_DATETIME',
111
        'float' => 'T_FLOAT',
112
        'int' => 'T_INT',
113
        'string' => 'T_STRING',
114
        'String' => 'T_TEXT',
115
        'STRING' => 'T_BLOB',
116
    ];
117
118
    /**
119
     * Maps column types reported by the database into PHP native/annotated types.
120
     * This is used by {@link Schema::getColumnInfo()}
121
     */
122
    protected const PHP_TYPES = [
123
        // bool
124
        'BOOLEAN' => 'bool',
125
        // int
126
        'BIGINT' => 'int',  // mysql
127
        'INTEGER' => 'int', // sqlite (must be this type to allow AUTOINCREMENT)
128
        // float
129
        'DOUBLE PRECISION' => 'float',
130
        // string <= 255
131
        'VARCHAR(255)' => 'string',
132
        // string <= 64k
133
        'TEXT' => 'String',     // @var String
134
        // string > 64k
135
        'BLOB' => 'STRING',     // @var STRING (sqlite)
136
        'LONGBLOB' => 'STRING', // @var STRING (mysql)
137
        // DateTime
138
        'DATETIME' => 'DateTime',
139
    ];
140
141
    /**
142
     * Driver-specific schema phrases.
143
     */
144
    protected const COLUMN_DEFINITIONS = [
145
        'mysql' => [
146
            self::T_AUTOINCREMENT => 'BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT',
147
            self::T_BLOB => 'LONGBLOB NOT NULL DEFAULT ""',
148
            self::T_BOOL => 'BOOLEAN NOT NULL DEFAULT 0',
149
            self::T_FLOAT => 'DOUBLE PRECISION NOT NULL DEFAULT 0',
150
            self::T_INT => 'BIGINT NOT NULL DEFAULT 0',
151
            self::T_STRING => 'VARCHAR(255) NOT NULL DEFAULT ""',
152
            self::T_TEXT => 'TEXT NOT NULL DEFAULT ""',
153
            self::T_DATETIME => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
154
            self::T_BLOB_NULL => 'LONGBLOB NULL DEFAULT NULL',
155
            self::T_BOOL_NULL => 'BOOLEAN NULL DEFAULT NULL',
156
            self::T_FLOAT_NULL => 'DOUBLE PRECISION NULL DEFAULT NULL',
157
            self::T_INT_NULL => 'BIGINT NULL DEFAULT NULL',
158
            self::T_STRING_NULL => 'VARCHAR(255) NULL DEFAULT NULL',
159
            self::T_TEXT_NULL => 'TEXT NULL DEFAULT NULL',
160
            self::T_DATETIME_NULL => 'DATETIME NULL DEFAULT NULL',
161
        ],
162
        'sqlite' => [
163
            self::T_AUTOINCREMENT => 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT',
164
            self::T_BLOB => 'BLOB NOT NULL DEFAULT ""',
165
            self::T_BOOL => 'BOOLEAN NOT NULL DEFAULT 0',
166
            self::T_FLOAT => 'DOUBLE PRECISION NOT NULL DEFAULT 0',
167
            self::T_INT => 'INTEGER NOT NULL DEFAULT 0',
168
            self::T_STRING => 'VARCHAR(255) NOT NULL DEFAULT ""',
169
            self::T_TEXT => 'TEXT NOT NULL DEFAULT ""',
170
            self::T_DATETIME => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
171
            self::T_BLOB_NULL => 'BLOB DEFAULT NULL',
172
            self::T_BOOL_NULL => 'BOOLEAN DEFAULT NULL',
173
            self::T_FLOAT_NULL => 'DOUBLE PRECISION DEFAULT NULL',
174
            self::T_INT_NULL => 'INTEGER DEFAULT NULL',
175
            self::T_STRING_NULL => 'VARCHAR(255) DEFAULT NULL',
176
            self::T_TEXT_NULL => 'TEXT DEFAULT NULL',
177
            self::T_DATETIME_NULL => 'DATETIME NULL DEFAULT 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
    {
201
        $this->db = $db;
202
        $this->colDefs ??= self::COLUMN_DEFINITIONS[$db->getDriver()];
203
    }
204
205
    /**
206
     * `ALTER TABLE $table ADD COLUMN $column ...` if it doesn't exist.
207
     *
208
     * @param string $table
209
     * @param string $column
210
     * @param int $type
211
     * @return $this
212
     */
213
    public function addColumn(string $table, string $column, int $type = self::T_STRING_NULL)
214
    {
215
        if (!$this->hasColumn($table, $column)) {
216
            $type = $this->colDefs[$type & self::T_MASK];
217
            $this->db->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$type}");
218
            unset($this->tables[$table]);
219
        }
220
        return $this;
221
    }
222
223
    /**
224
     * Driver-appropriate constraint creation.
225
     *
226
     * @param string $table
227
     * @param string[] $columns
228
     * @return $this
229
     */
230
    public function addUniqueKey(string $table, array $columns)
231
    {
232
        $name = $this->getUniqueKeyName($table, $columns);
233
        $columns = implode(',', $columns);
234
        if ($this->db->isSQLite()) {
235
            $this->db->exec("CREATE UNIQUE INDEX {$name} ON {$table} ({$columns})");
236
        } else {
237
            $this->db->exec("ALTER TABLE {$table} ADD CONSTRAINT {$name} UNIQUE ({$columns})");
238
        }
239
        return $this;
240
    }
241
242
    /**
243
     * `CREATE TABLE $table ...`
244
     *
245
     * At least one column must be given.
246
     *
247
     * @param string $table
248
     * @param int[] $columns `[ name => <I_CONST> | <T_CONST> ]`
249
     * @param Column[] $foreign `[ column name => <External Column> ]`
250
     * @return $this
251
     */
252
    public function createTable(string $table, array $columns, array $foreign = [])
253
    {
254
        assert(count($columns) > 0);
255
        $columns = $this->sortColumns($columns);
256
        $colDefs = [];
257
        $primaryKey = [];
258
259
        // column list
260
        foreach ($columns as $name => $type) {
261
            if ($type === self::T_AUTOINCREMENT) {
262
                $typeDef = $this->colDefs[self::T_AUTOINCREMENT];
263
            } else {
264
                $typeDef = $this->colDefs[$type & self::T_MASK];
265
                if ($type & self::I_PRIMARY) {
266
                    $primaryKey[] = $name;
267
                }
268
            }
269
            $colDefs[$name] = "{$name} {$typeDef}";
270
        }
271
272
        // non auto-increment primary key
273
        if ($primaryKey) {
0 ignored issues
show
introduced by
$primaryKey is an empty array, thus is always false.
Loading history...
Bug Best Practice introduced by
The expression $primaryKey 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...
274
            $colDefs[] = sprintf(
275
                'CONSTRAINT %s PRIMARY KEY (%s)',
276
                $this->getPrimaryKeyName($table, $primaryKey),
277
                implode(',', $primaryKey)
278
            );
279
        }
280
281
        // foreign keys
282
        foreach ($foreign as $local => $external) {
283
            $colDefs[] = sprintf(
284
                'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s) ON UPDATE CASCADE ON DELETE %s',
285
                $this->getForeignKeyName($table, $local),
286
                $local,
287
                $external->getQualifier(),
288
                $external->getName(),
289
                $columns[$local] | self::T_STRICT ? 'CASCADE' : 'SET NULL'
290
            );
291
        }
292
293
        $this->db->exec(sprintf(
294
            "CREATE TABLE %s (%s)",
295
            $table,
296
            implode(', ', $colDefs)
297
        ));
298
299
        return $this;
300
    }
301
302
    /**
303
     * `DROP TABLE IF EXISTS $table`
304
     *
305
     * @param string $table
306
     * @return $this
307
     */
308
    public function dropTable(string $table)
309
    {
310
        $this->db->exec("DROP TABLE IF EXISTS {$table}");
311
        unset($this->tables[$table]);
312
        return $this;
313
    }
314
315
    /**
316
     * Driver-appropriate constraint deletion.
317
     *
318
     * @param string $table
319
     * @param string[] $columns
320
     * @return $this
321
     */
322
    public function dropUniqueKey(string $table, array $columns)
323
    {
324
        $name = $this->getUniqueKeyName($table, $columns);
325
        if ($this->db->isSQLite()) {
326
            $this->db->exec("DROP INDEX {$name}");
327
        } else {
328
            $this->db->exec("DROP INDEX {$name} ON {$table}");
329
        }
330
        return $this;
331
    }
332
333
    /**
334
     * Returns column metadata in an associative array.
335
     *
336
     * Elements are:
337
     * - `name`
338
     * - `type`: PHP native/annotated type (as a string)
339
     * - `nullable`: boolean
340
     *
341
     * The returned `type` can be used to get a `T_CONST` name from {@link Schema::T_CONST_NAMES}
342
     *
343
     * @param string $table
344
     * @param string $column
345
     * @return array[] Keyed by name.
346
     */
347
    public function getColumnInfo(string $table): array
348
    {
349
        if ($this->db->isSQLite()) {
350
            $info = $this->db->query("PRAGMA table_info({$table})")->fetchAll();
351
            return array_combine(array_column($info, 'name'), array_map(fn(array $each) => [
352
                'name' => $each['name'],
353
                'type' => static::PHP_TYPES[$each['type']] ?? 'string',
354
                'nullable' => !$each['notnull'],
355
            ], $info));
356
        }
357
        $info = $this->db->query("SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = \"{$table}\" ORDER BY ordinal_position")->fetchAll();
358
        return array_combine(array_column($info, 'column_name'), array_map(fn(array $each) => [
359
            'name' => $each['column_name'],
360
            'type' => static::PHP_TYPES[$each['data_type']] ?? 'string',
361
            'nullable' => $each['is_nullable'] === 'YES',
362
        ], $info));
363
    }
364
365
    /**
366
     * @return DB
367
     */
368
    public function getDb()
369
    {
370
        return $this->db;
371
    }
372
373
    /**
374
     * `FK_TABLE__COLUMN`
375
     *
376
     * @param string $table
377
     * @param string $column
378
     * @return string
379
     */
380
    final public function getForeignKeyName(string $table, string $column): string
381
    {
382
        return 'FK_' . $table . '__' . $column;
383
    }
384
385
    /**
386
     * `PK_TABLE__COLUMN__COLUMN__COLUMN`
387
     *
388
     * @param string $table
389
     * @param string[] $columns
390
     * @return string
391
     */
392
    final public function getPrimaryKeyName(string $table, array $columns): string
393
    {
394
        sort($columns, SORT_STRING);
395
        return 'PK_' . $table . '__' . implode('__', $columns);
396
    }
397
398
    /**
399
     * @param string $name
400
     * @return null|Table
401
     */
402
    public function getTable(string $name)
403
    {
404
        if (!isset($this->tables[$name])) {
405
            if ($this->db->isSQLite()) {
406
                $info = $this->db->query("PRAGMA table_info({$name})")->fetchAll();
407
                $cols = array_column($info, 'name');
408
            } else {
409
                $cols = $this->db->query("SELECT column_name FROM information_schema.tables WHERE table_name = \"{$name}\"")->fetchAll(DB::FETCH_COLUMN);
410
            }
411
            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...
412
                return null;
413
            }
414
            $this->tables[$name] = Table::factory($this->db, $name, $cols);
415
        }
416
        return $this->tables[$name];
417
    }
418
419
    /**
420
     * `UQ_TABLE__COLUMN__COLUMN__COLUMN`
421
     *
422
     * @param string $table
423
     * @param string[] $columns
424
     * @return string
425
     */
426
    final public function getUniqueKeyName(string $table, array $columns): string
427
    {
428
        sort($columns, SORT_STRING);
429
        return 'UQ_' . $table . '__' . implode('__', $columns);
430
    }
431
432
    /**
433
     * @param string $table
434
     * @param string $column
435
     * @return bool
436
     */
437
    public function hasColumn(string $table, string $column): bool
438
    {
439
        $table = $this->getTable($table);
440
        return isset($table[$column]);
441
    }
442
443
    /**
444
     * @param string $table
445
     * @param string[] $columns
446
     * @return bool
447
     */
448
    public function hasUniqueKey(string $table, array $columns): bool
449
    {
450
        $name = $this->db->quote($this->getUniqueKeyName($table, $columns));
451
        if ($this->db->isSQLite()) {
452
            $exists = "SELECT 1 FROM sqlite_master WHERE type='index' and name={$name}";
453
        } else {
454
            $table = $this->db->quote($table);
455
            $exists = "SELECT 1 FROM information_schema.statistics WHERE TABLE_NAME={$table} AND INDEX_NAME={$name}";
456
        }
457
        return (bool)$this->db->query($exists)->fetchColumn();
458
    }
459
460
    /**
461
     * Whether a table exists.
462
     *
463
     * @param string $table
464
     * @return bool
465
     */
466
    final public function offsetExists($table): bool
467
    {
468
        return (bool)$this->offsetGet($table);
469
    }
470
471
    /**
472
     * Returns a table by name.
473
     *
474
     * @param string $table
475
     * @return null|Table
476
     */
477
    public function offsetGet($table)
478
    {
479
        return $this->getTable($table);
480
    }
481
482
    /**
483
     * @param $offset
484
     * @param $value
485
     * @throws LogicException
486
     */
487
    final public function offsetSet($offset, $value)
488
    {
489
        throw new LogicException('The schema cannot be altered this way.');
490
    }
491
492
    /**
493
     * @param $offset
494
     * @throws LogicException
495
     */
496
    final public function offsetUnset($offset)
497
    {
498
        throw new LogicException('The schema cannot be altered this way.');
499
    }
500
501
    /**
502
     * `ALTER TABLE $oldName RENAME TO $newName`
503
     *
504
     * @param string $oldName
505
     * @param string $newName
506
     * @return $this
507
     */
508
    public function renameTable(string $oldName, string $newName)
509
    {
510
        $this->db->exec("ALTER TABLE {$oldName} RENAME TO {$newName}");
511
        unset($this->tables[$oldName]);
512
        return $this;
513
    }
514
515
    /**
516
     * Sorts according to index priority, storage size/complexity, and name.
517
     *
518
     * @param int[] $types
519
     * @return int[]
520
     */
521
    protected function sortColumns(array $types): array
522
    {
523
        uksort($types, function (string $a, string $b) use ($types) {
524
            // descending type constant, ascending name
525
            return $types[$b] <=> $types[$a] ?: $a <=> $b;
526
        });
527
        return $types;
528
    }
529
530
}
531