Passed
Push — master ( c6f47c...6c478e )
by y
01:29
created

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