DB::offsetUnset()
last analyzed

Size

Total Lines 3
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
nc 1
nop 1
dl 0
loc 3
1
<?php
2
3
namespace Helix;
4
5
use ArrayAccess;
6
use Helix\DB\EntityInterface;
7
use Helix\DB\Fluent\ExpressionInterface;
8
use Helix\DB\Junction;
9
use Helix\DB\Migrator;
10
use Helix\DB\Record;
11
use Helix\DB\Schema;
12
use Helix\DB\Statement;
13
use Helix\DB\Table;
14
use Helix\DB\Transaction;
15
use LogicException;
16
use PDO;
17
use ReflectionFunction;
18
19
/**
20
 * Extends `PDO` and acts as a central access point for the schema.
21
 */
22
class DB extends PDO implements ArrayAccess
23
{
24
25
    /**
26
     * @var Junction[]
27
     */
28
    protected $junctions = [];
29
30
    /**
31
     * Notified whenever a query is executed or a statement is prepared.
32
     * This is a stub-closure by default.
33
     *
34
     * `fn($sql): void`
35
     *
36
     * @var callable
37
     */
38
    protected $logger;
39
40
    /**
41
     * The migrations directory for this connection.
42
     *
43
     * This can be set via class override, or configuration file (recommended).
44
     *
45
     * When using {@link DB::fromConfig()}, this defaults to `migrations/<CONNECTION NAME>`
46
     *
47
     * @var string
48
     */
49
    protected $migrations = 'migrations/default';
50
51
    /**
52
     * @var Record[]
53
     */
54
    protected $records = [];
55
56
    /**
57
     * @var Schema
58
     */
59
    protected $schema;
60
61
    /**
62
     * The count of open transactions/savepoints.
63
     *
64
     * @var int
65
     */
66
    protected $transactions = 0;
67
68
    /**
69
     * Returns a new connection using a configuration file.
70
     *
71
     * See `db.config.php` in the `test` directory for an example.
72
     *
73
     * @param string $connection
74
     * @param string $file
75
     * @return static
76
     */
77
    public static function fromConfig(string $connection = 'default', string $file = 'db.config.php')
78
    {
79
        $config = (include "{$file}")[$connection];
80
        $args = [
81
            $config['dsn'],
82
            $config['username'] ?? null,
83
            $config['password'] ?? null,
84
            $config['options'] ?? []
85
        ];
86
        $class = $config['class'] ?? static::class;
87
        $db = new $class(...$args);
88
        $db->logger = $config['logger'] ?? $db->logger;
89
        $db->migrations = $config['migrations'] ?? "migrations/{$connection}";
90
        return $db;
91
    }
92
93
    /**
94
     * Returns an array of `?` placeholder expressions.
95
     *
96
     * @param int $count
97
     * @return ExpressionInterface[]
98
     */
99
    public static function marks(int $count)
100
    {
101
        static $mark;
102
        $mark ??= new class implements ExpressionInterface {
103
104
            public function __toString()
105
            {
106
                return '?';
107
            }
108
        };
109
        return array_fill(0, $count, $mark);
110
    }
111
112
    /**
113
     * Converts an array of columns to `:named` placeholders for prepared queries.
114
     *
115
     * Qualified columns are slotted as `qualifier__column` (two underscores).
116
     *
117
     * @param string[] $columns
118
     * @return string[] `["column" => ":column"]`
119
     */
120
    public static function slots(array $columns)
121
    {
122
        $slots = [];
123
        foreach ($columns as $column) {
124
            $slots[$column] = ':' . str_replace('.', '__', $column);
125
        }
126
        return $slots;
127
    }
128
129
    /**
130
     * Sets various attributes to streamline operations.
131
     *
132
     * Registers missing SQLite functions.
133
     *
134
     * @param string $dsn
135
     * @param string $username
136
     * @param string $password
137
     * @param array $options
138
     */
139
    public function __construct($dsn, $username = null, $password = null, array $options = [])
140
    {
141
        $options[self::ATTR_STATEMENT_CLASS] ??= [Statement::class, [$this]];
142
        parent::__construct($dsn, $username, $password, $options);
143
        $this->logger ??= fn() => null;
144
        $this->schema ??= Schema::factory($this);
145
146
        // these options must not be changed.
147
        $this->setAttribute(self::ATTR_DEFAULT_FETCH_MODE, self::FETCH_ASSOC);
148
        $this->setAttribute(self::ATTR_EMULATE_PREPARES, false);
149
        $this->setAttribute(self::ATTR_ERRMODE, self::ERRMODE_EXCEPTION);
150
        $this->setAttribute(self::ATTR_STRINGIFY_FETCHES, false);
151
152
        // invoke driver-specific hooks
153
        $this->{"__construct_{$this->getDriver()}"}();
154
    }
155
156
    /**
157
     * MySQL construction hook.
158
     */
159
    protected function __construct_mysql(): void
160
    {
161
        $this->exec("SET time_zone = 'UTC'");
162
    }
163
164
    /**
165
     * SQLite construction hook.
166
     */
167
    protected function __construct_sqlite(): void
168
    {
169
        // polyfill deterministic sqlite functions
170
        $this->sqliteCreateFunctions([
171
            // https://www.sqlite.org/lang_mathfunc.html
172
            'ACOS' => 'acos',
173
            'ASIN' => 'asin',
174
            'ATAN' => 'atan',
175
            'CEIL' => 'ceil',
176
            'COS' => 'cos',
177
            'DEGREES' => 'rad2deg',
178
            'EXP' => 'exp',
179
            'FLOOR' => 'floor',
180
            'LN' => 'log',
181
            'LOG' => fn($b, $x) => log($x, $b),
182
            'LOG10' => 'log10',
183
            'LOG2' => fn($x) => log($x, 2),
184
            'PI' => 'pi',
185
            'POW' => 'pow',
186
            'RADIANS' => 'deg2rad',
187
            'SIN' => 'sin',
188
            'SQRT' => 'sqrt',
189
            'TAN' => 'tan',
190
191
            // these are not in sqlite at all but are in other dbms
192
            'CONV' => 'base_convert',
193
            'SIGN' => fn($x) => $x <=> 0
194
        ]);
195
196
        // non-deterministic
197
        $this->sqliteCreateFunctions([
198
            'RAND' => fn() => mt_rand() / mt_getrandmax(),
199
        ], false);
200
    }
201
202
    /**
203
     * Allows nested transactions by using `SAVEPOINT`
204
     *
205
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
206
     *
207
     * @return true
208
     */
209
    public function beginTransaction()
210
    {
211
        assert($this->transactions >= 0);
212
        if ($this->transactions === 0) {
213
            $this->log("BEGIN TRANSACTION");
214
            parent::beginTransaction();
215
        } else {
216
            $this->exec("SAVEPOINT SAVEPOINT_{$this->transactions}");
217
        }
218
        $this->transactions++;
219
        return true;
220
    }
221
222
    /**
223
     * Allows nested transactions by using `RELEASE SAVEPOINT`
224
     *
225
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
226
     *
227
     * @return true
228
     */
229
    public function commit()
230
    {
231
        assert($this->transactions > 0);
232
        if ($this->transactions === 1) {
233
            $this->log("COMMIT TRANSACTION");
234
            parent::commit();
235
        } else {
236
            $savepoint = $this->transactions - 1;
237
            $this->exec("RELEASE SAVEPOINT SAVEPOINT_{$savepoint}");
238
        }
239
        $this->transactions--;
240
        return true;
241
    }
242
243
    /**
244
     * Notifies the logger and executes.
245
     *
246
     * @param string $sql
247
     * @return int
248
     */
249
    public function exec($sql): int
250
    {
251
        $this->log($sql);
252
        return parent::exec($sql);
253
    }
254
255
    /**
256
     * Central point of object creation.
257
     *
258
     * Override this to override classes.
259
     *
260
     * The only thing that calls this should be {@link \Helix\DB\FactoryTrait}
261
     *
262
     * @param string $class
263
     * @param mixed ...$args
264
     * @return mixed
265
     */
266
    public function factory(string $class, ...$args)
267
    {
268
        return new $class($this, ...$args);
269
    }
270
271
    /**
272
     * The driver's name.
273
     *
274
     * - mysql
275
     * - pgsql
276
     * - sqlite
277
     *
278
     * @return string
279
     */
280
    final public function getDriver(): string
281
    {
282
        return $this->getAttribute(self::ATTR_DRIVER_NAME);
283
    }
284
285
    /**
286
     * Returns a {@link Junction} access object based on an annotated interface.
287
     *
288
     * @param string $interface
289
     * @return Junction
290
     */
291
    public function getJunction(string $interface)
292
    {
293
        return $this->junctions[$interface] ??= Junction::factory($this, $interface);
294
    }
295
296
    /**
297
     * @return callable
298
     */
299
    final public function getLogger()
300
    {
301
        return $this->logger;
302
    }
303
304
    /**
305
     * @param string $dir
306
     * @return Migrator
307
     */
308
    public function getMigrator()
309
    {
310
        return Migrator::factory($this, $this->migrations);
311
    }
312
313
    /**
314
     * Returns a {@link Record} access object based on an annotated class.
315
     *
316
     * @param string|EntityInterface $class
317
     * @return Record
318
     */
319
    public function getRecord($class)
320
    {
321
        $name = is_object($class) ? get_class($class) : $class;
322
        return $this->records[$name] ??= Record::factory($this, $class);
323
    }
324
325
    /**
326
     * @return Schema
327
     */
328
    public function getSchema()
329
    {
330
        return $this->schema;
331
    }
332
333
    /**
334
     * @return bool
335
     */
336
    final public function isMySQL(): bool
337
    {
338
        return $this->getDriver() === 'mysql';
339
    }
340
341
    /**
342
     * @return bool
343
     */
344
    final public function isPostgreSQL(): bool
345
    {
346
        return $this->getDriver() === 'pgsql';
347
    }
348
349
    /**
350
     * @return bool
351
     */
352
    final public function isSQLite(): bool
353
    {
354
        return $this->getDriver() === 'sqlite';
355
    }
356
357
    /**
358
     * @param string $sql
359
     */
360
    public function log(string $sql): void
361
    {
362
        call_user_func($this->logger, $sql);
363
    }
364
365
    /**
366
     * Returns a scoped transaction.
367
     *
368
     * @return Transaction
369
     */
370
    public function newTransaction()
371
    {
372
        return Transaction::factory($this);
373
    }
374
375
    /**
376
     * Whether a table exists.
377
     *
378
     * @param string $table
379
     * @return bool
380
     */
381
    final public function offsetExists($table): bool
382
    {
383
        return (bool)$this->offsetGet($table);
384
    }
385
386
    /**
387
     * Returns a table by name.
388
     *
389
     * @param string $table
390
     * @return null|Table
391
     */
392
    public function offsetGet($table)
393
    {
394
        return $this->schema->getTable($table);
395
    }
396
397
    /**
398
     * Throws.
399
     *
400
     * @param $offset
401
     * @param $value
402
     * @throws LogicException
403
     */
404
    final public function offsetSet($offset, $value)
405
    {
406
        throw new LogicException('The schema cannot be altered this way.');
407
    }
408
409
    /**
410
     * Throws.
411
     *
412
     * @param $offset
413
     * @throws LogicException
414
     */
415
    final public function offsetUnset($offset)
416
    {
417
        throw new LogicException('The schema cannot be altered this way.');
418
    }
419
420
    /**
421
     * Notifies the logger and prepares a statement.
422
     *
423
     * @param string $sql
424
     * @param array $options
425
     * @return Statement
426
     */
427
    public function prepare($sql, $options = [])
428
    {
429
        $this->log($sql);
430
        return parent::{'prepare'}($sql, $options);
431
    }
432
433
    /**
434
     * Notifies the logger and queries.
435
     *
436
     * @param string $sql
437
     * @param int $mode
438
     * @param mixed $arg3
439
     * @param array $ctorargs
440
     * @return Statement
441
     */
442
    public function query($sql, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, $arg3 = null, array $ctorargs = [])
443
    {
444
        $this->log($sql);
445
        return parent::{'query'}(...func_get_args());
446
    }
447
448
    /**
449
     * Quotes a value, with special considerations.
450
     *
451
     * - {@link ExpressionInterface} instances are returned as-is.
452
     * - Booleans and integers are returned as unquoted integer-string.
453
     * - Everything else is returned as a quoted string.
454
     *
455
     * @param bool|number|string|object $value
456
     * @param int $type Ignored.
457
     * @return string|ExpressionInterface
458
     */
459
    public function quote($value, $type = self::PARAM_STR)
460
    {
461
        if ($value instanceof ExpressionInterface) {
462
            return $value;
463
        }
464
        if ($value instanceof EntityInterface) {
465
            return (string)$value->getId();
466
        }
467
        switch (gettype($value)) {
468
            case 'integer' :
469
            case 'boolean' :
470
            case 'resource' :
471
                return (string)(int)$value;
472
            default:
473
                return parent::quote((string)$value);
474
        }
475
    }
476
477
    /**
478
     * Quotes an array of values. Keys are preserved.
479
     *
480
     * @param array $values
481
     * @return string[]
482
     */
483
    public function quoteArray(array $values)
484
    {
485
        return array_map([$this, 'quote'], $values);
486
    }
487
488
    /**
489
     * Returns a quoted, comma-separated list.
490
     *
491
     * @param array $values
492
     * @return string
493
     */
494
    public function quoteList(array $values): string
495
    {
496
        return implode(',', $this->quoteArray($values));
497
    }
498
499
    /**
500
     * Allows nested transactions by using `ROLLBACK TO SAVEPOINT`
501
     *
502
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
503
     *
504
     * @return true
505
     */
506
    public function rollBack()
507
    {
508
        assert($this->transactions > 0);
509
        if ($this->transactions === 1) {
510
            $this->log("ROLLBACK TRANSACTION");
511
            parent::rollBack();
512
        } else {
513
            $savepoint = $this->transactions - 1;
514
            $this->exec("ROLLBACK TO SAVEPOINT SAVEPOINT_{$savepoint}");
515
        }
516
        $this->transactions--;
517
        return true;
518
    }
519
520
    /**
521
     * @param callable $logger
522
     * @return $this
523
     */
524
    public function setLogger(callable $logger)
525
    {
526
        $this->logger = $logger;
527
        return $this;
528
    }
529
530
    /**
531
     * Installs a {@link Record} for a third-party class that cannot be annotated.
532
     *
533
     * @param string $class
534
     * @param Record $record
535
     * @return $this
536
     */
537
    public function setRecord(string $class, Record $record)
538
    {
539
        $this->records[$class] = $record;
540
        return $this;
541
    }
542
543
    /**
544
     * Create multiple SQLite functions at a time.
545
     *
546
     * @param callable[] $callables Keyed by function name.
547
     * @param bool $deterministic Whether the callables aren't random / are without side-effects.
548
     */
549
    public function sqliteCreateFunctions(array $callables, bool $deterministic = true): void
550
    {
551
        $deterministic = $deterministic ? self::SQLITE_DETERMINISTIC : 0;
552
        foreach ($callables as $name => $callable) {
553
            $argc = (new ReflectionFunction($callable))->getNumberOfRequiredParameters();
554
            $this->sqliteCreateFunction($name, $callable, $argc, $deterministic);
555
        }
556
    }
557
558
    /**
559
     * Performs work within a scoped transaction.
560
     *
561
     * The work is rolled back if an exception is thrown.
562
     *
563
     * @param callable $work `fn(DB $this): mixed`
564
     * @return mixed The return value of `$work`
565
     */
566
    public function transact(callable $work)
567
    {
568
        $transaction = $this->newTransaction();
569
        $return = call_user_func($work, $this);
570
        $transaction->commit();
571
        return $return;
572
    }
573
}
574