Passed
Push — master ( c6a312...8ed35d )
by y
05:46
created

DB::offsetUnset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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