Passed
Push — master ( 0edb33...04902e )
by y
01:38
created

Record::__construct_isNullable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 2
c 1
b 0
f 1
dl 0
loc 3
rs 10
cc 1
nc 1
nop 2
1
<?php
2
3
namespace Helix\DB;
4
5
use DateTime;
6
use DateTimeImmutable;
7
use DateTimeZone;
8
use Generator;
9
use Helix\DB;
10
use ReflectionClass;
11
use ReflectionNamedType;
12
use ReflectionProperty;
13
use stdClass;
14
15
/**
16
 * Represents an "active record" table, derived from an annotated class implementing {@link EntityInterface}.
17
 *
18
 * Class Annotations:
19
 *
20
 * - `@record TABLE`
21
 *
22
 * Property Annotations:
23
 *
24
 * - `@col` or `@column`
25
 * - `@eav <TABLE>`
26
 *
27
 * Property types are preserved.
28
 * Properties which are objects can be dehydrated/rehydrated if they're strictly typed.
29
 * Strict typing is preferred, but annotations and finally default values are used as fallbacks.
30
 *
31
 * > Annotating the types `String` (capital "S") or `STRING` (all caps) results in `TEXT` and `BLOB`
32
 *
33
 * @method static static factory(DB $db, EntityInterface $proto, string $table, array $columns, array $eav = [])
34
 *
35
 * @TODO Allow constraints in the `column` tag, supporting single and multi-column.
36
 */
37
class Record extends Table {
38
39
    protected const RX_RECORD = '/\*\h*@record\h+(?<table>\w+)/i';
40
    protected const RX_IS_COLUMN = '/\*\h*@col(umn)?\b/i';
41
    protected const RX_VAR = '/\*\h*@var\h+(?<type>\S+)/i'; // includes pipes and backslashes
42
    protected const RX_NULL = '/(\bnull\|)|(\|null\b)/i';
43
    protected const RX_EAV = '/\*\h*@eav\h+(?<table>\w+)/i';
44
    protected const RX_EAV_VAR = '/\*\h*@var\h+(?<type>\w+)\[\]/i'; // typed array
45
46
    /**
47
     * Maps complex types to storage types.
48
     *
49
     * @see Schema::T_CONST_NAMES keys
50
     */
51
    protected const DEHYDRATE_AS = [
52
        'array' => 'STRING', // blob. eav is better than this for 1D arrays.
53
        'object' => 'STRING', // blob.
54
        stdClass::class => 'STRING', // blob
55
        DateTime::class => 'DateTime',
56
        DateTimeImmutable::class => 'DateTime',
57
    ];
58
59
    /**
60
     * Maps annotated/native scalar types to storage types acceptable for `settype()`
61
     *
62
     * @see Schema::T_CONST_NAMES keys
63
     */
64
    const SCALARS = [
65
        'bool' => 'bool',
66
        'boolean' => 'bool',
67
        'double' => 'float',
68
        'false' => 'bool',
69
        'float' => 'float',
70
        'int' => 'int',
71
        'integer' => 'int',
72
        'number' => 'string',
73
        'scalar' => 'string',
74
        'string' => 'string',
75
        'String' => 'String',
76
        'STRING' => 'STRING',
77
        'true' => 'bool',
78
    ];
79
80
    /**
81
     * `[property => EAV]`
82
     *
83
     * @var EAV[]
84
     */
85
    protected $eav = [];
86
87
    /**
88
     * The specific classes used to hydrate classed properties, like `DateTime`.
89
     *
90
     * `[ property => class ]`
91
     *
92
     * @var string[]
93
     */
94
    protected $hydration = [];
95
96
    /**
97
     * `[ property => is nullable ]`
98
     *
99
     * @var bool[]
100
     */
101
    protected $nullable = [];
102
103
    /**
104
     * `[property => ReflectionProperty]`
105
     *
106
     * @var ReflectionProperty[]
107
     */
108
    protected $properties = [];
109
110
    /**
111
     * A boilerplate instance of the class, to clone and populate.
112
     *
113
     * @var EntityInterface
114
     */
115
    protected $proto;
116
117
    /**
118
     * Storage types.
119
     *
120
     * `[property => type]`
121
     *
122
     * @var string[]
123
     */
124
    protected $types = [];
125
126
    /**
127
     * @var DateTimeZone
128
     */
129
    protected DateTimeZone $utc;
130
131
    /**
132
     * Constructs record-access from an annotated class.
133
     *
134
     * If a prototype isn't given for `$class`, this defaults to creating an instance
135
     * via reflection (without invoking the constructor).
136
     *
137
     * @param DB $db
138
     * @param string|EntityInterface $class Class or prototype instance.
139
     * @return Record
140
     */
141
    public static function fromClass (DB $db, $class) {
142
        $rClass = new ReflectionClass($class);
143
        assert($rClass->isInstantiable());
144
        $columns = [];
145
        $EAV = [];
146
        foreach ($rClass->getProperties() as $rProp) {
147
            $doc = $rProp->getDocComment();
148
            if (preg_match(static::RX_IS_COLUMN, $doc)) {
149
                $columns[] = $rProp->getName();
150
            }
151
            elseif (preg_match(static::RX_EAV, $doc, $eav)) {
152
                preg_match(static::RX_EAV_VAR, $doc, $var);
153
                $type = $var['type'] ?? 'string';
154
                $type = static::SCALARS[$type] ?? 'string';
155
                $EAV[$rProp->getName()] = EAV::factory($db, $eav['table'], $type);
156
            }
157
        }
158
        preg_match(static::RX_RECORD, $rClass->getDocComment(), $record);
159
        if (!is_object($class)) {
160
            $class = $rClass->newInstanceWithoutConstructor();
161
        }
162
        return static::factory($db, $class, $record['table'], $columns, $EAV);
163
    }
164
165
    /**
166
     * @param DB $db
167
     * @param EntityInterface $proto
168
     * @param string $table
169
     * @param string[] $properties Property names.
170
     * @param EAV[] $eav Keyed by property name.
171
     */
172
    public function __construct (DB $db, EntityInterface $proto, string $table, array $properties, array $eav = []) {
173
        parent::__construct($db, $table, $properties);
174
        $this->proto = $proto;
175
        $this->utc = new DateTimeZone('UTC');
176
        $rClass = new ReflectionClass($proto);
177
        $defaults = $rClass->getDefaultProperties();
178
        foreach ($properties as $prop) {
179
            $rProp = $rClass->getProperty($prop);
180
            if (null === $type = $this->__construct_getType($prop, $rProp)) {
181
                $type = isset($defaults[$prop]) ? static::SCALARS[gettype($defaults[$prop])] : 'string';
182
            }
183
            if (null === $nullable = $this->__construct_isNullable($prop, $rProp)) {
184
                $nullable = !isset($defaults[$prop]);
185
            }
186
            assert(isset($type, $nullable));
187
            $rProp->setAccessible(true);
188
            $this->properties[$prop] = $rProp;
189
            $this->types[$prop] = $type;
190
            $this->nullable[$prop] = $nullable;
191
        }
192
        $this->types['id'] = 'int';
193
        $this->nullable['id'] = false;
194
        $this->eav = $eav;
195
        foreach (array_keys($eav) as $name) {
196
            $rProp = $rClass->getProperty($name);
197
            $rProp->setAccessible(true);
198
            $this->properties[$name] = $rProp;
199
        }
200
    }
201
202
    /**
203
     * Resolves a property's storage type during {@link Record::__construct()}
204
     *
205
     * Returns `null` for unknown. The constructor will fall back to checking the class default.
206
     *
207
     * @param string $prop
208
     * @param ReflectionProperty $rProp
209
     * @return null|string Storage type, or `null` for unknown.
210
     */
211
    protected function __construct_getType (string $prop, ReflectionProperty $rProp): ?string {
212
        return $this->__construct_getType_fromReflection($prop, $rProp)
213
            ?? $this->__construct_getType_fromVar($prop, $rProp);
214
    }
215
216
    /**
217
     * This also sets {@link Record::$hydration} for complex types.
218
     *
219
     * @param string $prop
220
     * @param ReflectionProperty $rProp
221
     * @return null|string
222
     */
223
    protected function __construct_getType_fromReflection (string $prop, ReflectionProperty $rProp): ?string {
224
        if ($rType = $rProp->getType() and $rType instanceof ReflectionNamedType) {
225
            $type = $rType->getName();
226
            if (isset(static::SCALARS[$type])) {
227
                return static::SCALARS[$type];
228
            }
229
            assert(isset(static::DEHYDRATE_AS[$type]));
230
            $this->hydration[$prop] = $type;
231
            return static::DEHYDRATE_AS[$type];
232
        }
233
        return null;
234
    }
235
236
    /**
237
     * This also sets {@link Record::$hydration} for complex types ONLY IF `@var` uses a FQN.
238
     *
239
     * @param string $prop
240
     * @param ReflectionProperty $rProp
241
     * @return null|string
242
     */
243
    protected function __construct_getType_fromVar (string $prop, ReflectionProperty $rProp): ?string {
244
        if (preg_match(static::RX_VAR, $rProp->getDocComment(), $var)) {
245
            $type = preg_replace(static::RX_NULL, '', $var['type']); // remove null
246
            if (isset(static::SCALARS[$type])) {
247
                return static::SCALARS[$type];
248
            }
249
            // it's beyond the scope of this class to parse "use" statements,
250
            // @var <CLASS> must be a FQN in order to work.
251
            $type = ltrim($type, '\\');
252
            if (isset(static::DEHYDRATE_AS[$type])) {
253
                $this->hydration[$prop] = $type;
254
                return static::DEHYDRATE_AS[$type];
255
            }
256
        }
257
        return null;
258
    }
259
260
    /**
261
     * Resolves a property's nullability during {@link Record::__construct()}
262
     *
263
     * Returns `null` for unknown. The constructor will fall back to checking the class default.
264
     *
265
     * @param string $prop
266
     * @param ReflectionProperty $rProp
267
     * @return null|bool
268
     */
269
    protected function __construct_isNullable (string $prop, ReflectionProperty $rProp): ?bool {
270
        return $this->__construct_isNullable_fromReflection($prop, $rProp)
271
            ?? $this->__construct_isNullable_fromVar($prop, $rProp);
272
    }
273
274
    /**
275
     * @param string $prop
276
     * @param ReflectionProperty $rProp
277
     * @return null|bool
278
     */
279
    protected function __construct_isNullable_fromReflection (string $prop, ReflectionProperty $rProp): ?bool {
280
        if ($rType = $rProp->getType()) {
281
            return $rType->allowsNull();
282
        }
283
        return null;
284
    }
285
286
    /**
287
     * @param string $prop
288
     * @param ReflectionProperty $rProp
289
     * @return null|bool
290
     */
291
    protected function __construct_isNullable_fromVar (string $prop, ReflectionProperty $rProp): ?bool {
292
        if (preg_match(static::RX_VAR, $rProp->getDocComment(), $var)) {
293
            preg_replace(static::RX_NULL, '', $var['type'], -1, $nullable);
294
            return (bool)$nullable;
295
        }
296
        return null;
297
    }
298
299
    /**
300
     * Fetches from a statement into clones of the entity prototype.
301
     *
302
     * @param Statement $statement
303
     * @return EntityInterface[] Keyed by ID
304
     */
305
    public function fetchAll (Statement $statement): array {
306
        return iterator_to_array($this->fetchEach($statement));
307
    }
308
309
    /**
310
     * Fetches in chunks and yields each loaded entity.
311
     * This is preferable over {@link fetchAll()} for iterating large result sets.
312
     *
313
     * @param Statement $statement
314
     * @return Generator|EntityInterface[] Keyed by ID
315
     */
316
    public function fetchEach (Statement $statement) {
317
        do {
318
            $entities = [];
319
            for ($i = 0; $i < 256 and false !== $row = $statement->fetch(); $i++) {
320
                $clone = clone $this->proto;
321
                $this->setValues($clone, $row);
322
                $entities[$row['id']] = $clone;
323
            }
324
            $this->loadEav($entities);
325
            yield from $entities;
326
        } while (!empty($entities));
327
    }
328
329
    /**
330
     * Similar to {@link loadAll()} except this can additionally search by {@link EAV} values.
331
     *
332
     * @see DB::match()
333
     *
334
     * @param array $match `[property => value]`
335
     * @param array[] $eavMatch `[eav property => attribute => value]`
336
     * @return Select|EntityInterface[]
337
     */
338
    public function findAll (array $match, array $eavMatch = []) {
339
        $select = $this->loadAll();
340
        foreach ($match as $a => $b) {
341
            $select->where($this->db->match($this[$a] ?? $a, $b));
342
        }
343
        foreach ($eavMatch as $property => $attributes) {
344
            $inner = $this->eav[$property]->findAll($attributes);
345
            $select->join($inner, $inner['entity']->isEqual($this['id']));
1 ignored issue
show
Bug introduced by
The method isEqual() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

345
            $select->join($inner, $inner['entity']->/** @scrutinizer ignore-call */ isEqual($this['id']));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
346
        }
347
        return $select;
348
    }
349
350
    /**
351
     * Returns an instance for the first row matching the criteria.
352
     *
353
     * @param array $match `[property => value]`
354
     * @param array $eavMatch `[eav property => attribute => value]`
355
     * @return null|EntityInterface
356
     */
357
    public function findFirst (array $match, array $eavMatch = []) {
358
        return $this->findAll($match, $eavMatch)->limit(1)->getFirst();
359
    }
360
361
    /**
362
     * @return string
363
     */
364
    final public function getClass (): string {
365
        return get_class($this->proto);
366
    }
367
368
    /**
369
     * @return EAV[]
370
     */
371
    public function getEav () {
372
        return $this->eav;
373
    }
374
375
    /**
376
     * Enumerated property names.
377
     *
378
     * @return string[]
379
     */
380
    final public function getProperties (): array {
381
        return array_keys($this->properties);
382
    }
383
384
    /**
385
     * @return EntityInterface
386
     */
387
    public function getProto () {
388
        return $this->proto;
389
    }
390
391
    /**
392
     * Returns a native/annotated property type.
393
     *
394
     * This doesn't include whether the property is nullable. Use {@link Record::isNullable()} for that.
395
     *
396
     * @param string $property
397
     * @return string
398
     */
399
    final public function getType (string $property): string {
400
        return $this->types[$property];
401
    }
402
403
    /**
404
     * Returns the native/annotated property types.
405
     *
406
     * This doesn't include whether the properties are nullable. Use {@link Record::isNullable()} for that.
407
     *
408
     * @return string[]
409
     */
410
    final public function getTypes (): array {
411
        return $this->types;
412
    }
413
414
    /**
415
     * @param EntityInterface $entity
416
     * @return array
417
     */
418
    protected function getValues (EntityInterface $entity): array {
419
        $values = [];
420
        foreach (array_keys($this->columns) as $name) {
421
            $value = $this->properties[$name]->getValue($entity);
422
            if (isset($value, $this->hydration[$name])) {
423
                $from = $this->hydration[$name];
424
                $to = static::DEHYDRATE_AS[$from];
425
                $value = $this->getValues_dehydrate($to, $from, $value);
426
            }
427
            $values[$name] = $value;
428
        }
429
        return $values;
430
    }
431
432
    /**
433
     * Dehydrates a complex property's value for storage in a scalar column.
434
     *
435
     * @see Record::setType_hydrate() inverse
436
     *
437
     * @param string $to The storage type.
438
     * @param string $from The strict type from the class definition.
439
     * @param array|object $hydrated
440
     * @return scalar
441
     */
442
    protected function getValues_dehydrate (string $to, string $from, $hydrated) {
443
        unset($from); // we don't need it here but it's given for posterity
444
        switch ($to) {
445
            case 'DateTime':
446
                assert($hydrated instanceof DateTime or $hydrated instanceof DateTimeImmutable);
447
                return (clone $hydrated)->setTimezone($this->utc)->format(Schema::DATETIME_FORMAT);
448
            default:
449
                return serialize($hydrated);
450
        }
451
    }
452
453
    /**
454
     * @param string $property
455
     * @return bool
456
     */
457
    final public function isNullable (string $property): bool {
458
        return $this->nullable[$property];
459
    }
460
461
    /**
462
     * Loads all data for a given ID into a clone of the prototype.
463
     *
464
     * @param int $id
465
     * @return null|EntityInterface
466
     */
467
    public function load (int $id) {
468
        $statement = $this->cache(__FUNCTION__, function() {
469
            return $this->select()->where('id = ?')->prepare();
470
        });
471
        $values = $statement([$id])->fetch();
472
        $statement->closeCursor();
473
        if ($values) {
474
            $entity = clone $this->proto;
475
            $this->setValues($entity, $values);
476
            $this->loadEav([$id => $entity]);
477
            return $entity;
478
        }
479
        return null;
480
    }
481
482
    /**
483
     * Returns a {@link Select} that fetches instances.
484
     *
485
     * @return Select|EntityInterface[]
486
     */
487
    public function loadAll () {
488
        return $this->select()->setFetcher(function(Statement $statement) {
489
            yield from $this->fetchEach($statement);
490
        });
491
    }
492
493
    /**
494
     * Loads and sets all EAV properties for an array of entities keyed by ID.
495
     *
496
     * @param EntityInterface[] $entities
497
     */
498
    protected function loadEav (array $entities): void {
499
        $ids = array_keys($entities);
500
        foreach ($this->eav as $name => $eav) {
501
            foreach ($eav->loadAll($ids) as $id => $values) {
502
                $this->properties[$name]->setValue($entities[$id], $values);
503
            }
504
        }
505
    }
506
507
    /**
508
     * Upserts record and EAV data.
509
     *
510
     * @param EntityInterface $entity
511
     * @return int ID
512
     */
513
    public function save (EntityInterface $entity): int {
514
        if (!$entity->getId()) {
515
            $this->saveInsert($entity);
516
        }
517
        else {
518
            $this->saveUpdate($entity);
519
        }
520
        $this->saveEav($entity);
521
        return $entity->getId();
522
    }
523
524
    /**
525
     * @param EntityInterface $entity
526
     */
527
    protected function saveEav (EntityInterface $entity): void {
528
        $id = $entity->getId();
529
        foreach ($this->eav as $name => $eav) {
530
            // may be null to skip
531
            $values = $this->properties[$name]->getValue($entity);
532
            if (isset($values)) {
533
                $eav->save($id, $values);
534
            }
535
        }
536
    }
537
538
    /**
539
     * Inserts a new row and updates the entity's ID.
540
     *
541
     * @param EntityInterface $entity
542
     */
543
    protected function saveInsert (EntityInterface $entity): void {
544
        $statement = $this->cache(__FUNCTION__, function() {
545
            $slots = $this->db->slots(array_keys($this->columns));
546
            unset($slots['id']);
547
            $columns = implode(',', array_keys($slots));
548
            $slots = implode(',', $slots);
549
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
550
        });
551
        $values = $this->getValues($entity);
552
        unset($values['id']);
553
        $this->properties['id']->setValue($entity, $statement($values)->getId());
554
        $statement->closeCursor();
555
    }
556
557
    /**
558
     * Updates the existing row for the entity.
559
     *
560
     * @param EntityInterface $entity
561
     */
562
    protected function saveUpdate (EntityInterface $entity): void {
563
        $statement = $this->cache(__FUNCTION__, function() {
564
            $slots = $this->db->slotsEqual(array_keys($this->columns));
565
            unset($slots['id']);
566
            $slots = implode(', ', $slots);
567
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
568
        });
569
        $statement->execute($this->getValues($entity));
570
        $statement->closeCursor();
571
    }
572
573
    /**
574
     * @param EntityInterface $proto
575
     * @return $this
576
     */
577
    public function setProto (EntityInterface $proto) {
578
        $this->proto = $proto;
579
        return $this;
580
    }
581
582
    /**
583
     * Converts a value from storage into the native/annotated type.
584
     *
585
     * @param string $property
586
     * @param mixed $value
587
     * @return mixed
588
     */
589
    protected function setType (string $property, $value) {
590
        if (isset($value)) {
591
            // complex?
592
            if (isset($this->hydration[$property])) {
593
                $to = $this->hydration[$property];
594
                $from = static::DEHYDRATE_AS[$to];
595
                return $this->setType_hydrate($to, $from, $value);
596
            }
597
            // scalar. this function doesn't care about the type's letter case.
598
            settype($value, $this->types[$property]);
599
        }
600
        return $value;
601
    }
602
603
    /**
604
     * Hydrates a complex value from scalar storage.
605
     *
606
     * @see Record::getValues_dehydrate() inverse
607
     *
608
     * @param string $to The strict type from the class definition.
609
     * @param string $from The storage type.
610
     * @param scalar $dehydrated
611
     * @return array|object
612
     */
613
    protected function setType_hydrate (string $to, string $from, $dehydrated) {
614
        switch ($from) {
615
            case 'DateTime':
616
                /**
617
                 * $to might be "DateTime", "DateTimeImmutable", or an extension.
618
                 *
619
                 * @see DateTime::createFromFormat()
620
                 */
621
                return call_user_func(
622
                    [$to, 'createFromFormat'],
623
                    'Y-m-d H:i:s',
624
                    $dehydrated,
625
                    $this->utc
626
                );
627
            default:
628
                return unserialize($dehydrated);
0 ignored issues
show
Bug introduced by
It seems like $dehydrated can also be of type boolean; however, parameter $data of unserialize() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

628
                return unserialize(/** @scrutinizer ignore-type */ $dehydrated);
Loading history...
629
        }
630
    }
631
632
    /**
633
     * @param EntityInterface $entity
634
     * @param array $values
635
     */
636
    protected function setValues (EntityInterface $entity, array $values): void {
637
        foreach ($values as $name => $value) {
638
            if (isset($this->properties[$name])) {
639
                $this->properties[$name]->setValue($entity, $this->setType($name, $value));
640
            }
641
            else {
642
                // attempt to set unknown fields directly on the instance.
643
                $entity->{$name} = $value;
644
            }
645
        }
646
    }
647
}