Passed
Push — master ( f39217...bc6df6 )
by y
02:20
created

anonymous//src/DB.php$0   A

Complexity

Total Complexity 1

Size/Duplication

Total Lines 4
Duplicated Lines 0 %

Importance

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