Passed
Push — master ( e1c603...5834c4 )
by y
01:26
created

DB.php$0 ➔ pi()   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 Countable;
7
use Helix\DB\EntityInterface;
8
use Helix\DB\Fluent\DateTime;
9
use Helix\DB\Fluent\ExpressionInterface;
10
use Helix\DB\Fluent\Num;
11
use Helix\DB\Junction;
12
use Helix\DB\Migrator;
13
use Helix\DB\Record;
14
use Helix\DB\Schema;
15
use Helix\DB\Statement;
16
use Helix\DB\Table;
17
use Helix\DB\Transaction;
18
use LogicException;
19
use PDO;
20
use ReflectionFunction;
21
22
/**
23
 * Extends `PDO` and acts as a central access point for the schema.
24
 */
25
class DB extends PDO implements ArrayAccess
26
{
27
28
    /**
29
     * The driver's name.
30
     *
31
     * - mysql
32
     * - sqlite
33
     *
34
     * @var string
35
     */
36
    protected $driver;
37
38
    /**
39
     * @var Junction[]
40
     */
41
    protected $junctions = [];
42
43
    /**
44
     * Notified whenever a query is executed or a statement is prepared.
45
     * This is a stub-closure by default.
46
     *
47
     * `fn($sql): void`
48
     *
49
     * @var callable
50
     */
51
    protected $logger;
52
53
    /**
54
     * The migrations directory for this connection.
55
     *
56
     * This can be set via class override, or configuration file (recommended).
57
     *
58
     * When using {@link DB::fromConfig()}, this defaults to `migrations/<CONNECTION NAME>`
59
     *
60
     * @var string
61
     */
62
    protected $migrations = 'migrations/default';
63
64
    /**
65
     * @var Record[]
66
     */
67
    protected $records = [];
68
69
    /**
70
     * @var Schema
71
     */
72
    protected $schema;
73
74
    /**
75
     * The count of open transactions/savepoints.
76
     *
77
     * @var int
78
     */
79
    protected $transactions = 0;
80
81
    /**
82
     * Returns a new connection using a configuration file.
83
     *
84
     * See `db.config.php` in the `test` directory for an example.
85
     *
86
     * @param string $connection
87
     * @param string $file
88
     * @return static
89
     */
90
    public static function fromConfig(string $connection = 'default', string $file = 'db.config.php')
91
    {
92
        $config = (include "{$file}")[$connection];
93
        $args = [
94
            $config['dsn'],
95
            $config['username'] ?? null,
96
            $config['password'] ?? null,
97
            $config['options'] ?? []
98
        ];
99
        $class = $config['class'] ?? static::class;
100
        $db = new $class(...$args);
101
        $db->logger = $config['logger'] ?? $db->logger;
102
        $db->migrations = $config['migrations'] ?? "migrations/{$connection}";
103
        return $db;
104
    }
105
106
    /**
107
     * Returns an array of `?` placeholder expressions.
108
     *
109
     * @param int|array|Countable $count
110
     * @return ExpressionInterface[]
111
     */
112
    public static function marks($count)
113
    {
114
        static $mark;
115
        $mark ??= new class implements ExpressionInterface {
116
117
            public function __toString()
118
            {
119
                return '?';
120
            }
121
        };
122
        return array_fill(0, is_int($count) ? $count : count($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)
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)
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
     * The driver's name.
213
     *
214
     * @return string
215
     */
216
    final public function __toString(): string
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->log("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->log("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 and executes.
264
     *
265
     * @param string $sql
266
     * @return int
267
     */
268
    public function exec($sql): int
269
    {
270
        $this->log($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(string $interface)
305
    {
306
        return $this->junctions[$interface] ??= Junction::factory($this, $interface);
307
    }
308
309
    /**
310
     * @return callable
311
     */
312
    final 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
        $name = is_object($class) ? get_class($class) : $class;
335
        return $this->records[$name] ??= Record::factory($this, $class);
336
    }
337
338
    /**
339
     * @return Schema
340
     */
341
    public function getSchema()
342
    {
343
        return $this->schema;
344
    }
345
346
    /**
347
     * @return bool
348
     */
349
    final public function isMySQL(): bool
350
    {
351
        return $this->driver === 'mysql';
352
    }
353
354
    /**
355
     * @return bool
356
     */
357
    final public function isPostgreSQL(): bool
358
    {
359
        return $this->driver === 'pgsql';
360
    }
361
362
    /**
363
     * @return bool
364
     */
365
    final public function isSQLite(): bool
366
    {
367
        return $this->driver === 'sqlite';
368
    }
369
370
    /**
371
     * @param string $sql
372
     */
373
    public function log(string $sql): void
374
    {
375
        call_user_func($this->logger, $sql);
376
    }
377
378
    /**
379
     * Returns a scoped transaction.
380
     *
381
     * @return Transaction
382
     */
383
    public function newTransaction()
384
    {
385
        return Transaction::factory($this);
386
    }
387
388
    /**
389
     * Whether a table exists.
390
     *
391
     * @param string $table
392
     * @return bool
393
     */
394
    final public function offsetExists($table): bool
395
    {
396
        return (bool)$this->offsetGet($table);
397
    }
398
399
    /**
400
     * Returns a table by name.
401
     *
402
     * @param string $table
403
     * @return null|Table
404
     */
405
    public function offsetGet($table)
406
    {
407
        return $this->schema->getTable($table);
408
    }
409
410
    /**
411
     * Throws.
412
     *
413
     * @param $offset
414
     * @param $value
415
     * @throws LogicException
416
     */
417
    final public function offsetSet($offset, $value)
418
    {
419
        throw new LogicException('The schema cannot be altered this way.');
420
    }
421
422
    /**
423
     * Throws.
424
     *
425
     * @param $offset
426
     * @throws LogicException
427
     */
428
    final public function offsetUnset($offset)
429
    {
430
        throw new LogicException('The schema cannot be altered this way.');
431
    }
432
433
    /**
434
     * Notifies the logger and prepares a statement.
435
     *
436
     * @param string $sql
437
     * @param array $options
438
     * @return Statement
439
     */
440
    public function prepare($sql, $options = [])
441
    {
442
        $this->log($sql);
443
        return parent::{'prepare'}($sql, $options);
444
    }
445
446
    /**
447
     * Notifies the logger and queries.
448
     *
449
     * @param string $sql
450
     * @param int $mode
451
     * @param mixed $arg3
452
     * @param array $ctorargs
453
     * @return Statement
454
     */
455
    public function query($sql, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, $arg3 = null, array $ctorargs = [])
456
    {
457
        $this->log($sql);
458
        return parent::{'query'}(...func_get_args());
459
    }
460
461
    /**
462
     * Quotes a value, with special considerations.
463
     *
464
     * - {@link ExpressionInterface} instances are returned as-is.
465
     * - Booleans and integers are returned as unquoted integer-string.
466
     * - Everything else is returned as a quoted string.
467
     *
468
     * @param bool|number|string|object $value
469
     * @param int $type Ignored.
470
     * @return string|ExpressionInterface
471
     */
472
    public function quote($value, $type = self::PARAM_STR)
473
    {
474
        if ($value instanceof ExpressionInterface) {
475
            return $value;
476
        }
477
        if ($value instanceof EntityInterface) {
478
            return (string)$value->getId();
479
        }
480
        switch (gettype($value)) {
481
            case 'integer' :
482
            case 'boolean' :
483
            case 'resource' :
484
                return (string)(int)$value;
485
            default:
486
                return parent::quote((string)$value);
487
        }
488
    }
489
490
    /**
491
     * Quotes an array of values. Keys are preserved.
492
     *
493
     * @param array $values
494
     * @return string[]
495
     */
496
    public function quoteArray(array $values)
497
    {
498
        return array_map([$this, 'quote'], $values);
499
    }
500
501
    /**
502
     * Returns a quoted, comma-separated list.
503
     *
504
     * @param array $values
505
     * @return string
506
     */
507
    public function quoteList(array $values): string
508
    {
509
        return implode(',', $this->quoteArray($values));
510
    }
511
512
    /**
513
     * `RAND()` float between `0` and `1`
514
     *
515
     * @return Num
516
     */
517
    public function rand()
518
    {
519
        return Num::factory($this, "RAND()");
520
    }
521
522
    /**
523
     * Allows nested transactions by using `ROLLBACK TO SAVEPOINT`
524
     *
525
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
526
     *
527
     * @return true
528
     */
529
    public function rollBack()
530
    {
531
        assert($this->transactions > 0);
532
        if ($this->transactions === 1) {
533
            $this->log("ROLLBACK TRANSACTION");
534
            parent::rollBack();
535
        } else {
536
            $savepoint = $this->transactions - 1;
537
            $this->exec("ROLLBACK TO SAVEPOINT SAVEPOINT_{$savepoint}");
538
        }
539
        $this->transactions--;
540
        return true;
541
    }
542
543
    /**
544
     * Forwards to the entity's {@link Record}
545
     *
546
     * @param EntityInterface $entity
547
     * @return int ID
548
     */
549
    public function save(EntityInterface $entity): int
550
    {
551
        return $this->getRecord($entity)->save($entity);
552
    }
553
554
    /**
555
     * @param callable $logger
556
     * @return $this
557
     */
558
    public function setLogger(callable $logger)
559
    {
560
        $this->logger = $logger;
561
        return $this;
562
    }
563
564
    /**
565
     * Installs a {@link Record} for a third-party class that cannot be annotated.
566
     *
567
     * @param string $class
568
     * @param Record $record
569
     * @return $this
570
     */
571
    public function setRecord(string $class, Record $record)
572
    {
573
        $this->records[$class] = $record;
574
        return $this;
575
    }
576
577
    /**
578
     * Create multiple SQLite functions at a time.
579
     *
580
     * @param callable[] $callables Keyed by function name.
581
     * @param bool $deterministic Whether the callables aren't random / are without side-effects.
582
     */
583
    public function sqliteCreateFunctions(array $callables, bool $deterministic = true): void
584
    {
585
        $deterministic = $deterministic ? self::SQLITE_DETERMINISTIC : 0;
586
        foreach ($callables as $name => $callable) {
587
            $argc = (new ReflectionFunction($callable))->getNumberOfRequiredParameters();
588
            $this->sqliteCreateFunction($name, $callable, $argc, $deterministic);
589
        }
590
    }
591
592
    /**
593
     * An expression for the current date at midnight.
594
     *
595
     * @return DateTime
596
     */
597
    public function today()
598
    {
599
        if ($this->isSQLite()) {
600
            return DateTime::factory($this, "DATE('now')");
601
        }
602
        return DateTime::factory($this, 'CURRENT_DATE()');
603
    }
604
605
    /**
606
     * Performs work within a scoped transaction.
607
     *
608
     * The work is rolled back if an exception is thrown.
609
     *
610
     * @param callable $work `fn(): mixed`
611
     * @return mixed The return value of `$work`
612
     */
613
    public function transact(callable $work)
614
    {
615
        $transaction = $this->newTransaction();
616
        $return = call_user_func($work);
617
        $transaction->commit();
618
        return $return;
619
    }
620
}
621