Passed
Branch master (fddfe5)
by y
03:08 queued 48s
created

DB::getRecord()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 5
rs 10
cc 2
nc 2
nop 1
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
     * Sets various attributes to streamline operations.
65
     *
66
     * Registers missing SQLite functions.
67
     *
68
     * @param string $dsn
69
     * @param string $username
70
     * @param string $password
71
     * @param array $options
72
     */
73
    public function __construct ($dsn, $username = null, $password = null, array $options = []) {
74
        $options += [
75
            self::ATTR_STATEMENT_CLASS => [Statement::class, [$this]]
76
        ];
77
        parent::__construct($dsn, $username, $password, $options);
78
        $this->setAttribute(self::ATTR_DEFAULT_FETCH_MODE, self::FETCH_ASSOC);
79
        $this->setAttribute(self::ATTR_EMULATE_PREPARES, false);
80
        $this->setAttribute(self::ATTR_ERRMODE, self::ERRMODE_EXCEPTION);
81
        $this->setAttribute(self::ATTR_STRINGIFY_FETCHES, false);
82
        $this->logger ??= fn() => null;
83
        $this->driver = $this->getAttribute(self::ATTR_DRIVER_NAME);
84
85
        if ($this->isSQLite()) {
86
            // polyfill sqlite functions
87
            $this->sqliteCreateFunctions([ // deterministic functions
88
                // https://www.sqlite.org/lang_mathfunc.html
89
                'ACOS' => 'acos',
90
                'ASIN' => 'asin',
91
                'ATAN' => 'atan',
92
                'CEIL' => 'ceil',
93
                'COS' => 'cos',
94
                'DEGREES' => 'rad2deg',
95
                'EXP' => 'exp',
96
                'FLOOR' => 'floor',
97
                'LN' => 'log',
98
                'LOG' => fn($b, $x) => log($x, $b),
99
                'LOG10' => 'log10',
100
                'LOG2' => fn($x) => log($x, 2),
101
                'PI' => 'pi',
102
                'POW' => 'pow',
103
                'RADIANS' => 'deg2rad',
104
                'SIN' => 'sin',
105
                'SQRT' => 'sqrt',
106
                'TAN' => 'tan',
107
108
                // these are not in sqlite at all but are in other dbms
109
                'CONV' => 'base_convert',
110
                'SIGN' => fn($x) => ($x > 0) - ($x < 0),
111
            ]);
112
113
            $this->sqliteCreateFunctions([ // non-deterministic
114
                'RAND' => fn() => mt_rand(0, 1),
115
            ], false);
116
        }
117
    }
118
119
    /**
120
     * Returns the driver.
121
     *
122
     * @return string
123
     */
124
    final public function __toString () {
125
        return $this->driver;
126
    }
127
128
    /**
129
     * Allows nested transactions by using `SAVEPOINT`
130
     *
131
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
132
     *
133
     * @return true
134
     */
135
    public function beginTransaction () {
136
        assert($this->transactions >= 0);
137
        if ($this->transactions === 0) {
138
            $this->logger->__invoke("BEGIN TRANSACTION");
139
            parent::beginTransaction();
140
        }
141
        else {
142
            $this->exec("SAVEPOINT SAVEPOINT_{$this->transactions}");
143
        }
144
        $this->transactions++;
145
        return true;
146
    }
147
148
    /**
149
     * Allows nested transactions by using `RELEASE SAVEPOINT`
150
     *
151
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
152
     *
153
     * @return true
154
     */
155
    public function commit () {
156
        assert($this->transactions > 0);
157
        if ($this->transactions === 1) {
158
            $this->logger->__invoke("COMMIT TRANSACTION");
159
            parent::commit();
160
        }
161
        else {
162
            $savepoint = $this->transactions - 1;
163
            $this->exec("RELEASE SAVEPOINT SAVEPOINT_{$savepoint}");
164
        }
165
        $this->transactions--;
166
        return true;
167
    }
168
169
    /**
170
     * Notifies the logger.
171
     *
172
     * @param string $sql
173
     * @return int
174
     */
175
    public function exec ($sql): int {
176
        $this->logger->__invoke($sql);
177
        return parent::exec($sql);
178
    }
179
180
    /**
181
     * Central point of object creation.
182
     *
183
     * Override this to override classes.
184
     *
185
     * The only thing that calls this should be {@link \Helix\DB\FactoryTrait}
186
     *
187
     * @param string $class
188
     * @param mixed ...$args
189
     * @return mixed
190
     */
191
    public function factory (string $class, ...$args) {
192
        return new $class($this, ...$args);
193
    }
194
195
    /**
196
     * @return string
197
     */
198
    final public function getDriver (): string {
199
        return $this->driver;
200
    }
201
202
    /**
203
     * Returns a {@link Junction} access object based on an annotated interface.
204
     *
205
     * @param string $interface
206
     * @return Junction
207
     */
208
    public function getJunction ($interface) {
209
        return $this->junctions[$interface] ??= Junction::fromInterface($this, $interface);
210
    }
211
212
    /**
213
     * @return Closure
214
     */
215
    public function getLogger () {
216
        return $this->logger;
217
    }
218
219
    /**
220
     * Returns a {@link Record} access object based on an annotated class.
221
     *
222
     * @param string|EntityInterface $class
223
     * @return Record
224
     */
225
    public function getRecord ($class) {
226
        if (is_object($class)) {
227
            $class = get_class($class);
228
        }
229
        return $this->records[$class] ??= Record::fromClass($this, $class);
230
    }
231
232
    /**
233
     * @param string $name
234
     * @return null|Table
235
     */
236
    public function getTable (string $name) {
237
        if (!isset($this->tables[$name])) {
238
            if ($this->isSQLite()) {
239
                $info = $this->query("PRAGMA table_info({$this->quote($name)})")->fetchAll();
240
                $cols = array_column($info, 'name');
241
            }
242
            else {
243
                $cols = $this->query(
244
                    "SELECT column_name FROM information_schema.tables WHERE table_name = {$this->quote($name)}"
245
                )->fetchAll(self::FETCH_COLUMN);
246
            }
247
            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...
248
                return null;
249
            }
250
            $this->tables[$name] = Table::factory($this, $name, $cols);
251
        }
252
        return $this->tables[$name];
253
    }
254
255
    /**
256
     * @return bool
257
     */
258
    final public function isMySQL (): bool {
259
        return $this->driver === 'mysql';
260
    }
261
262
    /**
263
     * @return bool
264
     */
265
    final public function isPostgreSQL (): bool {
266
        return $this->driver === 'pgsql';
267
    }
268
269
    /**
270
     * @return bool
271
     */
272
    final public function isSQLite (): bool {
273
        return $this->driver === 'sqlite';
274
    }
275
276
    /**
277
     * Generates an equality {@link Predicate} from mixed arguments.
278
     *
279
     * If `$b` is a closure, returns from `$b($a, DB $this)`
280
     *
281
     * If `$a` is an integer (enumerated item), returns `$b` as a {@link Predicate}
282
     *
283
     * If `$b` is an array, returns `$a IN (...quoted $b)`
284
     *
285
     * If `$b` is a {@link Select}, returns `$a IN ($b->toSql())`
286
     *
287
     * Otherwise predicates `$a = quoted $b`
288
     *
289
     * @param mixed $a
290
     * @param mixed $b
291
     * @return Predicate
292
     */
293
    public function match ($a, $b) {
294
        if ($b instanceof Closure) {
295
            return $b->__invoke($a, $this);
296
        }
297
        if (is_int($a)) {
298
            return Predicate::factory($this, $b);
299
        }
300
        if (is_array($b)) {
301
            return Predicate::factory($this, "{$a} IN ({$this->quoteList($b)})");
302
        }
303
        if ($b instanceof Select) {
304
            return Predicate::factory($this, "{$a} IN ({$b->toSql()})");
305
        }
306
        return Predicate::factory($this, "{$a} = {$this->quote($b)}");
307
    }
308
309
    /**
310
     * Returns a scoped transaction.
311
     *
312
     * @return Transaction
313
     */
314
    public function newTransaction () {
315
        return Transaction::factory($this);
316
    }
317
318
    /**
319
     * Whether a table exists.
320
     *
321
     * @param string $table
322
     * @return bool
323
     */
324
    final public function offsetExists ($table): bool {
325
        return (bool)$this->getTable($table);
326
    }
327
328
    /**
329
     * Returns a table by name.
330
     *
331
     * @param string $table
332
     * @return null|Table
333
     */
334
    final public function offsetGet ($table) {
335
        return $this->getTable($table);
336
    }
337
338
    /**
339
     * @param $offset
340
     * @param $value
341
     * @throws LogicException
342
     */
343
    final public function offsetSet ($offset, $value) {
344
        throw new LogicException('Raw table access is immutable.');
345
    }
346
347
    /**
348
     * @param $offset
349
     * @throws LogicException
350
     */
351
    final public function offsetUnset ($offset) {
352
        throw new LogicException('Raw table access is immutable.');
353
    }
354
355
    /**
356
     * `PI()`
357
     *
358
     * @return Num
359
     */
360
    public function pi () {
361
        return Num::factory($this, "PI()");
362
    }
363
364
    /**
365
     * Notifies the logger.
366
     *
367
     * @param string $sql
368
     * @param array $options
369
     * @return Statement
370
     */
371
    public function prepare ($sql, $options = []) {
372
        $this->logger->__invoke($sql);
373
        /** @var Statement $statement */
374
        $statement = parent::prepare($sql, $options);
375
        return $statement;
376
    }
377
378
    /**
379
     * Notifies the logger and executes.
380
     *
381
     * @param string $sql
382
     * @param int $mode
383
     * @param mixed $arg3 Optional.
384
     * @param array $ctorargs Optional.
385
     * @return Statement
386
     */
387
    public function query ($sql, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, $arg3 = null, array $ctorargs = []) {
388
        $this->logger->__invoke($sql);
389
        /** @var Statement $statement */
390
        $statement = parent::query(...func_get_args());
391
        return $statement;
392
    }
393
394
    /**
395
     * Quotes a value, with special considerations.
396
     *
397
     * - {@link ExpressionInterface} instances are returned as-is.
398
     * - Booleans and integers are returned as unquoted integer-string.
399
     * - Everything else is returned as a quoted string.
400
     *
401
     * @param bool|number|string|object $value
402
     * @param int $type Ignored.
403
     * @return string|ExpressionInterface
404
     */
405
    public function quote ($value, $type = self::PARAM_STR) {
406
        if ($value instanceof ExpressionInterface) {
407
            return $value;
408
        }
409
        switch (gettype($value)) {
410
            case 'integer' :
411
            case 'boolean' :
412
            case 'resource' :
413
                return (string)(int)$value;
414
            default:
415
                return parent::quote((string)$value);
416
        }
417
    }
418
419
    /**
420
     * Quotes an array of values. Keys are preserved.
421
     *
422
     * @param array $values
423
     * @return string[]
424
     */
425
    public function quoteArray (array $values) {
426
        return array_map([$this, 'quote'], $values);
427
    }
428
429
    /**
430
     * Returns a quoted, comma-separated list.
431
     *
432
     * @param array $values
433
     * @return string
434
     */
435
    public function quoteList (array $values): string {
436
        return implode(',', $this->quoteArray($values));
437
    }
438
439
    /**
440
     * `RAND()` float between `0` and `1`
441
     *
442
     * @return Num
443
     */
444
    public function rand () {
445
        return Num::factory($this, "RAND()");
446
    }
447
448
    /**
449
     * Allows nested transactions by using `ROLLBACK TO SAVEPOINT`
450
     *
451
     * Use {@link DB::newTransaction()} to work with {@link Transaction} instead.
452
     *
453
     * @return true
454
     */
455
    public function rollBack () {
456
        assert($this->transactions > 0);
457
        if ($this->transactions === 1) {
458
            $this->logger->__invoke("ROLLBACK TRANSACTION");
459
            parent::rollBack();
460
        }
461
        else {
462
            $savepoint = $this->transactions - 1;
463
            $this->exec("ROLLBACK TO SAVEPOINT SAVEPOINT_{$savepoint}");
464
        }
465
        $this->transactions--;
466
        return true;
467
    }
468
469
    /**
470
     * Forwards to the entity's {@link Record}
471
     *
472
     * @param EntityInterface $entity
473
     * @return int ID
474
     */
475
    public function save (EntityInterface $entity): int {
476
        return $this->getRecord($entity)->save($entity);
477
    }
478
479
    /**
480
     * @param string $interface
481
     * @param Junction $junction
482
     * @return $this
483
     */
484
    public function setJunction (string $interface, Junction $junction) {
485
        $this->junctions[$interface] = $junction;
486
        return $this;
487
    }
488
489
    /**
490
     * @param Closure $logger
491
     * @return $this
492
     */
493
    public function setLogger (Closure $logger) {
494
        $this->logger = $logger;
495
        return $this;
496
    }
497
498
    /**
499
     * @param string $class
500
     * @param Record $record
501
     * @return $this
502
     */
503
    public function setRecord (string $class, Record $record) {
504
        $this->records[$class] = $record;
505
        return $this;
506
    }
507
508
    /**
509
     * @param callable[] $callbacks Keyed by function name.
510
     * @param bool $deterministic Whether the callbacks aren't random / are without side-effects.
511
     */
512
    public function sqliteCreateFunctions (array $callbacks, bool $deterministic = true): void {
513
        $deterministic = $deterministic ? self::SQLITE_DETERMINISTIC : 0;
514
        foreach ($callbacks as $name => $callback) {
515
            $argc = (new ReflectionFunction($callback))->getNumberOfRequiredParameters();
516
            $this->sqliteCreateFunction($name, $callback, $argc, $deterministic);
517
        }
518
    }
519
520
    /**
521
     * Performs work within a scoped transaction.
522
     *
523
     * The work is rolled back if an exception is thrown.
524
     *
525
     * @param callable $work
526
     * @return mixed The return value of `$work`
527
     */
528
    public function transact (callable $work) {
529
        $transaction = $this->newTransaction();
530
        $return = call_user_func($work);
531
        $transaction->commit();
532
        return $return;
533
    }
534
}