Passed
Push — master ( 3084e3...f39217 )
by y
01:35
created

DB::fromConfig()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 2
1
<?php
2
3
namespace Helix;
4
5
use ArrayAccess;
6
use Closure;
7
use Helix\DB\EntityInterface;
8
use Helix\DB\Junction;
9
use Helix\DB\Record;
10
use Helix\DB\Select;
11
use Helix\DB\SQL\ExpressionInterface;
12
use Helix\DB\SQL\Num;
13
use Helix\DB\SQL\Predicate;
14
use Helix\DB\Statement;
15
use Helix\DB\Table;
16
use Helix\DB\Transaction;
17
use LogicException;
18
use PDO;
19
use ReflectionFunction;
20
21
/**
22
 * Extends `PDO` and acts as a central access point for the schema.
23
 */
24
class DB extends PDO implements ArrayAccess {
25
26
    /**
27
     * @var string
28
     */
29
    private $driver;
30
31
    /**
32
     * @var Junction[]
33
     */
34
    protected $junctions = [];
35
36
    /**
37
     * Notified whenever a query is executed or a statement is prepared.
38
     * This is a stub closure by default.
39
     *
40
     * `fn($sql):void`
41
     *
42
     * @var Closure
43
     */
44
    protected $logger;
45
46
    /**
47
     * @var Record[]
48
     */
49
    protected $records = [];
50
51
    /**
52
     * @var Table[]
53
     */
54
    protected $tables = [];
55
56
    /**
57
     * The count of open transactions/savepoints.
58
     *
59
     * @var int
60
     */
61
    protected $transactions = 0;
62
63
    /**
64
     * Returns an instance using a configuration file.
65
     *
66
     * See `db.config.php` in the `test` directory for an example.
67
     *
68
     * @param string $connection
69
     * @param string $file
70
     * @return static
71
     */
72
    public static function fromConfig (string $connection = 'default', string $file = 'db.config.php') {
73
        $config = (include "{$file}")[$connection];
74
        return new static($config['dsn'], $config['username'] ?? null, $config['password'] ?? null, $config['options'] ?? []);
75
    }
76
77
    /**
78
     * Sets various attributes to streamline operations.
79
     *
80
     * Registers missing SQLite functions.
81
     *
82
     * @param string $dsn
83
     * @param string $username
84
     * @param string $password
85
     * @param array $options
86
     */
87
    public function __construct ($dsn, $username = null, $password = null, array $options = []) {
88
        $options[self::ATTR_STATEMENT_CLASS] ??= [Statement::class, [$this]];
89
        parent::__construct($dsn, $username, $password, $options);
90
        $this->setAttribute(self::ATTR_DEFAULT_FETCH_MODE, self::FETCH_ASSOC);
91
        $this->setAttribute(self::ATTR_EMULATE_PREPARES, false);
92
        $this->setAttribute(self::ATTR_ERRMODE, self::ERRMODE_EXCEPTION);
93
        $this->setAttribute(self::ATTR_STRINGIFY_FETCHES, false);
94
        $this->logger ??= fn() => null;
95
        $this->driver = $this->getAttribute(self::ATTR_DRIVER_NAME);
96
97
        if ($this->isSQLite()) {
98
            // polyfill sqlite functions
99
            $this->sqliteCreateFunctions([ // deterministic functions
100
                // https://www.sqlite.org/lang_mathfunc.html
101
                'ACOS' => 'acos',
102
                'ASIN' => 'asin',
103
                'ATAN' => 'atan',
104
                'CEIL' => 'ceil',
105
                'COS' => 'cos',
106
                'DEGREES' => 'rad2deg',
107
                'EXP' => 'exp',
108
                'FLOOR' => 'floor',
109
                'LN' => 'log',
110
                'LOG' => fn($b, $x) => log($x, $b),
111
                'LOG10' => 'log10',
112
                'LOG2' => fn($x) => log($x, 2),
113
                'PI' => 'pi',
114
                'POW' => 'pow',
115
                'RADIANS' => 'deg2rad',
116
                'SIN' => 'sin',
117
                'SQRT' => 'sqrt',
118
                'TAN' => 'tan',
119
120
                // these are not in sqlite at all but are in other dbms
121
                'CONV' => 'base_convert',
122
                'SIGN' => fn($x) => ($x > 0) - ($x < 0),
123
            ]);
124
125
            $this->sqliteCreateFunctions([ // non-deterministic
126
                'RAND' => fn() => mt_rand() / mt_getrandmax(),
127
            ], false);
128
        }
129
    }
130
131
    /**
132
     * Returns the driver.
133
     *
134
     * @return string
135
     */
136
    final public function __toString () {
137
        return $this->driver;
138
    }
139
140
    /**
141
     * Allows nested transactions by using `SAVEPOINT`
142
     *
143
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
144
     *
145
     * @return true
146
     */
147
    public function beginTransaction () {
148
        assert($this->transactions >= 0);
149
        if ($this->transactions === 0) {
150
            $this->logger->__invoke("BEGIN TRANSACTION");
151
            parent::beginTransaction();
152
        }
153
        else {
154
            $this->exec("SAVEPOINT SAVEPOINT_{$this->transactions}");
155
        }
156
        $this->transactions++;
157
        return true;
158
    }
159
160
    /**
161
     * Allows nested transactions by using `RELEASE SAVEPOINT`
162
     *
163
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
164
     *
165
     * @return true
166
     */
167
    public function commit () {
168
        assert($this->transactions > 0);
169
        if ($this->transactions === 1) {
170
            $this->logger->__invoke("COMMIT TRANSACTION");
171
            parent::commit();
172
        }
173
        else {
174
            $savepoint = $this->transactions - 1;
175
            $this->exec("RELEASE SAVEPOINT SAVEPOINT_{$savepoint}");
176
        }
177
        $this->transactions--;
178
        return true;
179
    }
180
181
    /**
182
     * Notifies the logger.
183
     *
184
     * @param string $sql
185
     * @return int
186
     */
187
    public function exec ($sql): int {
188
        $this->logger->__invoke($sql);
189
        return parent::exec($sql);
190
    }
191
192
    /**
193
     * Central point of object creation.
194
     *
195
     * Override this to override classes.
196
     *
197
     * The only thing that calls this should be {@link \Helix\DB\FactoryTrait}
198
     *
199
     * @param string $class
200
     * @param mixed ...$args
201
     * @return mixed
202
     */
203
    public function factory (string $class, ...$args) {
204
        return new $class($this, ...$args);
205
    }
206
207
    /**
208
     * @return string
209
     */
210
    final public function getDriver (): string {
211
        return $this->driver;
212
    }
213
214
    /**
215
     * Returns a {@link Junction} access object based on an annotated interface.
216
     *
217
     * @param string $interface
218
     * @return Junction
219
     */
220
    public function getJunction ($interface) {
221
        return $this->junctions[$interface] ??= Junction::fromInterface($this, $interface);
222
    }
223
224
    /**
225
     * @return Closure
226
     */
227
    public function getLogger () {
228
        return $this->logger;
229
    }
230
231
    /**
232
     * Returns a {@link Record} access object based on an annotated class.
233
     *
234
     * @param string|EntityInterface $class
235
     * @return Record
236
     */
237
    public function getRecord ($class) {
238
        if (is_object($class)) {
239
            $class = get_class($class);
240
        }
241
        return $this->records[$class] ??= Record::fromClass($this, $class);
242
    }
243
244
    /**
245
     * @param string $name
246
     * @return null|Table
247
     */
248
    public function getTable (string $name) {
249
        if (!isset($this->tables[$name])) {
250
            if ($this->isSQLite()) {
251
                $info = $this->query("PRAGMA table_info({$this->quote($name)})")->fetchAll();
252
                $cols = array_column($info, 'name');
253
            }
254
            else {
255
                $cols = $this->query(
256
                    "SELECT column_name FROM information_schema.tables WHERE table_name = {$this->quote($name)}"
257
                )->fetchAll(self::FETCH_COLUMN);
258
            }
259
            if (!$cols) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cols of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
260
                return null;
261
            }
262
            $this->tables[$name] = Table::factory($this, $name, $cols);
263
        }
264
        return $this->tables[$name];
265
    }
266
267
    /**
268
     * @return bool
269
     */
270
    final public function isMySQL (): bool {
271
        return $this->driver === 'mysql';
272
    }
273
274
    /**
275
     * @return bool
276
     */
277
    final public function isPostgreSQL (): bool {
278
        return $this->driver === 'pgsql';
279
    }
280
281
    /**
282
     * @return bool
283
     */
284
    final public function isSQLite (): bool {
285
        return $this->driver === 'sqlite';
286
    }
287
288
    /**
289
     * Generates an equality {@link Predicate} from mixed arguments.
290
     *
291
     * If `$b` is a closure, returns from `$b($a, DB $this)`
292
     *
293
     * If `$a` is an integer (enumerated item), returns `$b` as a {@link Predicate}
294
     *
295
     * If `$b` is an array, returns `$a IN (...quoted $b)`
296
     *
297
     * If `$b` is a {@link Select}, returns `$a IN ($b->toSql())`
298
     *
299
     * Otherwise predicates `$a = quoted $b`
300
     *
301
     * @param mixed $a
302
     * @param mixed $b
303
     * @return Predicate
304
     */
305
    public function match ($a, $b) {
306
        if ($b instanceof Closure) {
307
            return $b->__invoke($a, $this);
308
        }
309
        if (is_int($a)) {
310
            return Predicate::factory($this, $b);
311
        }
312
        if (is_array($b)) {
313
            return Predicate::factory($this, "{$a} IN ({$this->quoteList($b)})");
314
        }
315
        if ($b instanceof Select) {
316
            return Predicate::factory($this, "{$a} IN ({$b->toSql()})");
317
        }
318
        return Predicate::factory($this, "{$a} = {$this->quote($b)}");
319
    }
320
321
    /**
322
     * Returns a scoped transaction.
323
     *
324
     * @return Transaction
325
     */
326
    public function newTransaction () {
327
        return Transaction::factory($this);
328
    }
329
330
    /**
331
     * Whether a table exists.
332
     *
333
     * @param string $table
334
     * @return bool
335
     */
336
    final public function offsetExists ($table): bool {
337
        return (bool)$this->getTable($table);
338
    }
339
340
    /**
341
     * Returns a table by name.
342
     *
343
     * @param string $table
344
     * @return null|Table
345
     */
346
    final public function offsetGet ($table) {
347
        return $this->getTable($table);
348
    }
349
350
    /**
351
     * @param $offset
352
     * @param $value
353
     * @throws LogicException
354
     */
355
    final public function offsetSet ($offset, $value) {
356
        throw new LogicException('Raw table access is immutable.');
357
    }
358
359
    /**
360
     * @param $offset
361
     * @throws LogicException
362
     */
363
    final public function offsetUnset ($offset) {
364
        throw new LogicException('Raw table access is immutable.');
365
    }
366
367
    /**
368
     * `PI()`
369
     *
370
     * @return Num
371
     */
372
    public function pi () {
373
        return Num::factory($this, "PI()");
374
    }
375
376
    /**
377
     * Notifies the logger.
378
     *
379
     * @param string $sql
380
     * @param array $options
381
     * @return Statement
382
     */
383
    public function prepare ($sql, $options = []) {
384
        $this->logger->__invoke($sql);
385
        /** @var Statement $statement */
386
        $statement = parent::prepare($sql, $options);
387
        return $statement;
388
    }
389
390
    /**
391
     * Notifies the logger and executes.
392
     *
393
     * @param string $sql
394
     * @param int $mode
395
     * @param mixed $arg3 Optional.
396
     * @param array $ctorargs Optional.
397
     * @return Statement
398
     */
399
    public function query ($sql, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, $arg3 = null, array $ctorargs = []) {
400
        $this->logger->__invoke($sql);
401
        /** @var Statement $statement */
402
        $statement = parent::query(...func_get_args());
403
        return $statement;
404
    }
405
406
    /**
407
     * Quotes a value, with special considerations.
408
     *
409
     * - {@link ExpressionInterface} instances are returned as-is.
410
     * - Booleans and integers are returned as unquoted integer-string.
411
     * - Everything else is returned as a quoted string.
412
     *
413
     * @param bool|number|string|object $value
414
     * @param int $type Ignored.
415
     * @return string|ExpressionInterface
416
     */
417
    public function quote ($value, $type = self::PARAM_STR) {
418
        if ($value instanceof ExpressionInterface) {
419
            return $value;
420
        }
421
        switch (gettype($value)) {
422
            case 'integer' :
423
            case 'boolean' :
424
            case 'resource' :
425
                return (string)(int)$value;
426
            default:
427
                return parent::quote((string)$value);
428
        }
429
    }
430
431
    /**
432
     * Quotes an array of values. Keys are preserved.
433
     *
434
     * @param array $values
435
     * @return string[]
436
     */
437
    public function quoteArray (array $values) {
438
        return array_map([$this, 'quote'], $values);
439
    }
440
441
    /**
442
     * Returns a quoted, comma-separated list.
443
     *
444
     * @param array $values
445
     * @return string
446
     */
447
    public function quoteList (array $values): string {
448
        return implode(',', $this->quoteArray($values));
449
    }
450
451
    /**
452
     * `RAND()` float between `0` and `1`
453
     *
454
     * @return Num
455
     */
456
    public function rand () {
457
        return Num::factory($this, "RAND()");
458
    }
459
460
    /**
461
     * Allows nested transactions by using `ROLLBACK TO SAVEPOINT`
462
     *
463
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
464
     *
465
     * @return true
466
     */
467
    public function rollBack () {
468
        assert($this->transactions > 0);
469
        if ($this->transactions === 1) {
470
            $this->logger->__invoke("ROLLBACK TRANSACTION");
471
            parent::rollBack();
472
        }
473
        else {
474
            $savepoint = $this->transactions - 1;
475
            $this->exec("ROLLBACK TO SAVEPOINT SAVEPOINT_{$savepoint}");
476
        }
477
        $this->transactions--;
478
        return true;
479
    }
480
481
    /**
482
     * Forwards to the entity's {@link Record}
483
     *
484
     * @param EntityInterface $entity
485
     * @return int ID
486
     */
487
    public function save (EntityInterface $entity): int {
488
        return $this->getRecord($entity)->save($entity);
489
    }
490
491
    /**
492
     * @param string $interface
493
     * @param Junction $junction
494
     * @return $this
495
     */
496
    public function setJunction (string $interface, Junction $junction) {
497
        $this->junctions[$interface] = $junction;
498
        return $this;
499
    }
500
501
    /**
502
     * @param Closure $logger
503
     * @return $this
504
     */
505
    public function setLogger (Closure $logger) {
506
        $this->logger = $logger;
507
        return $this;
508
    }
509
510
    /**
511
     * @param string $class
512
     * @param Record $record
513
     * @return $this
514
     */
515
    public function setRecord (string $class, Record $record) {
516
        $this->records[$class] = $record;
517
        return $this;
518
    }
519
520
    /**
521
     * @param callable[] $callbacks Keyed by function name.
522
     * @param bool $deterministic Whether the callbacks aren't random / are without side-effects.
523
     */
524
    public function sqliteCreateFunctions (array $callbacks, bool $deterministic = true): void {
525
        $deterministic = $deterministic ? self::SQLITE_DETERMINISTIC : 0;
526
        foreach ($callbacks as $name => $callback) {
527
            $argc = (new ReflectionFunction($callback))->getNumberOfRequiredParameters();
528
            $this->sqliteCreateFunction($name, $callback, $argc, $deterministic);
529
        }
530
    }
531
532
    /**
533
     * Performs work within a scoped transaction.
534
     *
535
     * The work is rolled back if an exception is thrown.
536
     *
537
     * @param callable $work
538
     * @return mixed The return value of `$work`
539
     */
540
    public function transact (callable $work) {
541
        $transaction = $this->newTransaction();
542
        $return = call_user_func($work);
543
        $transaction->commit();
544
        return $return;
545
    }
546
}