Passed
Push — master ( bd73b6...71c175 )
by y
06:37
created

DB.php$0 ➔ today()   A

Complexity

Conditions 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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