Passed
Push — master ( 71c175...699f14 )
by y
01:29
created

DB.php$0 ➔ log()   A

Complexity

Conditions 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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