Passed
Push — master ( ec8004...0edb33 )
by y
02:18
created

Record::getValues_dehydrate()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 8
rs 10
cc 3
nc 2
nop 3
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
42
    protected const RX_NULL = '/\b\|?null\|?\b/i'; // leading or trailing only
43
    protected const RX_IS_SCALAR = '/^bool(ean)?|int(eger)?|float|double|string$/i';
44
    protected const RX_EAV = '/\*\h*@eav\h+(?<table>\w+)/i';
45
    protected const RX_EAV_VAR = '/\*\h*@var\h+(?<type>\w+)\[\]/i'; // typed array
46
47
    /**
48
     * @see Schema::T_CONST_NAMES
49
     */
50
    protected const DEHYDRATE_AS = [
51
        'array' => 'string', // eav is better than this
52
        'object' => 'string',
53
        stdClass::class => 'string',
54
        DateTime::class => 'DateTime',
55
        DateTimeImmutable::class => 'DateTime',
56
    ];
57
58
    /**
59
     * `[property => EAV]`
60
     *
61
     * @var EAV[]
62
     */
63
    protected $eav = [];
64
65
    /**
66
     * The specific classes used to hydrate classed properties, like `DateTime`.
67
     *
68
     * `[ property => class ]`
69
     *
70
     * @var string[]
71
     */
72
    protected $hydration = [];
73
74
    /**
75
     * `[ property => is nullable ]`
76
     *
77
     * @var bool[]
78
     */
79
    protected $nullable = [];
80
81
    /**
82
     * `[property => ReflectionProperty]`
83
     *
84
     * @var ReflectionProperty[]
85
     */
86
    protected $properties = [];
87
88
    /**
89
     * A boilerplate instance of the class, to clone and populate.
90
     * This defaults to a naively created instance without invoking the constructor.
91
     *
92
     * @var EntityInterface
93
     */
94
    protected $proto;
95
96
    /**
97
     * Scalar property types. Types may be nullable.
98
     *
99
     *  - `bool`
100
     *  - `float` or `double`
101
     *  - `int`
102
     *  - `string`
103
     *
104
     * `[property => type]`
105
     *
106
     * @var string[]
107
     */
108
    protected $types = [];
109
110
    /**
111
     * @var DateTimeZone
112
     */
113
    protected DateTimeZone $utc;
114
115
    /**
116
     * @param DB $db
117
     * @param string|EntityInterface $class
118
     * @return Record
119
     */
120
    public static function fromClass (DB $db, $class) {
121
        $rClass = new ReflectionClass($class);
122
        assert($rClass->isInstantiable());
123
        $columns = [];
124
        $EAV = [];
125
        foreach ($rClass->getProperties() as $rProp) {
126
            $doc = $rProp->getDocComment();
127
            if (preg_match(static::RX_IS_COLUMN, $doc)) {
128
                $columns[] = $rProp->getName();
129
            }
130
            elseif (preg_match(static::RX_EAV, $doc, $eav)) {
131
                preg_match(static::RX_EAV_VAR, $doc, $var);
132
                $EAV[$rProp->getName()] = EAV::factory($db, $eav['table'], $var['type'] ?? 'string');
133
            }
134
        }
135
        preg_match(static::RX_RECORD, $rClass->getDocComment(), $record);
136
        if (!is_object($class)) {
137
            $class = $rClass->newInstanceWithoutConstructor();
138
        }
139
        return static::factory($db, $class, $record['table'], $columns, $EAV);
140
    }
141
142
    /**
143
     * @param DB $db
144
     * @param EntityInterface $proto
145
     * @param string $table
146
     * @param string[] $columns Property names.
147
     * @param EAV[] $eav Keyed by property name.
148
     */
149
    public function __construct (DB $db, EntityInterface $proto, string $table, array $columns, array $eav = []) {
150
        parent::__construct($db, $table, $columns);
151
        $this->proto = $proto;
152
        $this->utc = new DateTimeZone('UTC');
153
        $rClass = new ReflectionClass($proto);
154
        $defaults = $rClass->getDefaultProperties();
155
        foreach ($columns as $prop) { // TODO maybe break this up into helper methods
156
            $rProp = $rClass->getProperty($prop);
157
            $rProp->setAccessible(true);
158
            $this->properties[$prop] = $rProp;
159
            // infer the type from reflection
160
            if ($rType = $rProp->getType() and $rType instanceof ReflectionNamedType) {
161
                if (preg_match(static::RX_IS_SCALAR, $rType->getName())) {
162
                    $type = $rType->getName();
163
                }
164
                else { // "array", "object", class name
165
                    $type = self::DEHYDRATE_AS[$rType->getName()];
166
                    $this->hydration[$prop] = $rType->getName();
167
                }
168
                $nullable = $rType->allowsNull();
169
            }
170
            // infer scalar type from @var
171
            elseif (preg_match(static::RX_VAR, $rProp->getDocComment(), $var)) {
172
                $type = $var['type'];
173
                // extract nullable
174
                $type = preg_replace(static::RX_NULL, '', $type, -1, $nullable);
175
                $nullable = (bool)$nullable;
176
                // must be scalar
177
                assert(preg_match(static::RX_IS_SCALAR, $type));
178
            }
179
            // infer the type from the default value
180
            else {
181
                if (isset($defaults[$prop])) {
182
                    $type = gettype($defaults[$prop]);
183
                    $nullable = false;
184
                }
185
                else {
186
                    $type = 'string';
187
                    $nullable = true;
188
                }
189
            }
190
            $this->types[$prop] = $type;
191
            $this->nullable[$prop] = $nullable;
192
        }
193
        $this->types['id'] = 'int';
194
        $this->nullable['id'] = false;
195
        $this->eav = $eav;
196
        foreach (array_keys($eav) as $name) {
197
            $rProp = $rClass->getProperty($name);
198
            $rProp->setAccessible(true);
199
            $this->properties[$name] = $rProp;
200
        }
201
    }
202
203
    /**
204
     * Fetches from a statement into clones of the entity prototype.
205
     *
206
     * @param Statement $statement
207
     * @return EntityInterface[] Keyed by ID
208
     */
209
    public function fetchAll (Statement $statement): array {
210
        return iterator_to_array($this->fetchEach($statement));
211
    }
212
213
    /**
214
     * Fetches in chunks and yields each loaded entity.
215
     * This is preferable over {@link fetchAll()} for iterating large result sets.
216
     *
217
     * @param Statement $statement
218
     * @return Generator|EntityInterface[] Keyed by ID
219
     */
220
    public function fetchEach (Statement $statement) {
221
        do {
222
            $entities = [];
223
            for ($i = 0; $i < 256 and false !== $row = $statement->fetch(); $i++) {
224
                $clone = clone $this->proto;
225
                $this->setValues($clone, $row);
226
                $entities[$row['id']] = $clone;
227
            }
228
            $this->loadEav($entities);
229
            yield from $entities;
230
        } while (!empty($entities));
231
    }
232
233
    /**
234
     * Similar to {@link loadAll()} except this can additionally search by {@link EAV} values.
235
     *
236
     * @see DB::match()
237
     *
238
     * @param array $match `[property => value]`
239
     * @param array[] $eavMatch `[eav property => attribute => value]`
240
     * @return Select|EntityInterface[]
241
     */
242
    public function findAll (array $match, array $eavMatch = []) {
243
        $select = $this->loadAll();
244
        foreach ($match as $a => $b) {
245
            $select->where($this->db->match($this[$a] ?? $a, $b));
246
        }
247
        foreach ($eavMatch as $property => $attributes) {
248
            $inner = $this->eav[$property]->findAll($attributes);
249
            $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

249
            $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...
250
        }
251
        return $select;
252
    }
253
254
    /**
255
     * Returns an instance for the first row matching the criteria.
256
     *
257
     * @param array $match `[property => value]`
258
     * @param array $eavMatch `[eav property => attribute => value]`
259
     * @return null|EntityInterface
260
     */
261
    public function findFirst (array $match, array $eavMatch = []) {
262
        return $this->findAll($match, $eavMatch)->limit(1)->getFirst();
263
    }
264
265
    /**
266
     * @return string
267
     */
268
    final public function getClass (): string {
269
        return get_class($this->proto);
270
    }
271
272
    /**
273
     * @return EAV[]
274
     */
275
    public function getEav () {
276
        return $this->eav;
277
    }
278
279
    /**
280
     * Enumerated property names.
281
     *
282
     * @return string[]
283
     */
284
    final public function getProperties (): array {
285
        return array_keys($this->properties);
286
    }
287
288
    /**
289
     * @return EntityInterface
290
     */
291
    public function getProto () {
292
        return $this->proto;
293
    }
294
295
    /**
296
     * Returns a native/annotated property type.
297
     *
298
     * This doesn't include whether the property is nullable. Use {@link Record::isNullable()} for that.
299
     *
300
     * @param string $property
301
     * @return string
302
     */
303
    final public function getType (string $property): string {
304
        return $this->types[$property];
305
    }
306
307
    /**
308
     * Returns the native/annotated property types.
309
     *
310
     * This doesn't include whether the properties are nullable. Use {@link Record::isNullable()} for that.
311
     *
312
     * @return string[]
313
     */
314
    final public function getTypes (): array {
315
        return $this->types;
316
    }
317
318
    /**
319
     * @param EntityInterface $entity
320
     * @return array
321
     */
322
    protected function getValues (EntityInterface $entity): array {
323
        $values = [];
324
        foreach (array_keys($this->columns) as $name) {
325
            $value = $this->properties[$name]->getValue($entity);
326
            if (isset($value, $this->hydration[$name])) {
327
                $from = $this->hydration[$name];
328
                $to = static::DEHYDRATE_AS[$from];
329
                $value = $this->getValues_dehydrate($to, $from, $value);
330
            }
331
            $values[$name] = $value;
332
        }
333
        return $values;
334
    }
335
336
    /**
337
     * Dehydrates a complex property's value for storage in a scalar column.
338
     *
339
     * @see Record::setType_hydrate() inverse
340
     *
341
     * @param string $to The storage type.
342
     * @param string $from The strict type from the class definition.
343
     * @param array|object $hydrated
344
     * @return scalar
345
     */
346
    protected function getValues_dehydrate (string $to, string $from, $hydrated) {
347
        unset($from); // we don't need it here but it's given for posterity
348
        switch ($to) {
349
            case 'DateTime':
350
                assert($hydrated instanceof DateTime or $hydrated instanceof DateTimeImmutable);
351
                return (clone $hydrated)->setTimezone($this->utc)->format(Schema::DATETIME_FORMAT);
352
            default:
353
                return serialize($hydrated);
354
        }
355
    }
356
357
    /**
358
     * @param string $property
359
     * @return bool
360
     */
361
    final public function isNullable (string $property): bool {
362
        return $this->nullable[$property];
363
    }
364
365
    /**
366
     * Loads all data for a given ID into a clone of the prototype.
367
     *
368
     * @param int $id
369
     * @return null|EntityInterface
370
     */
371
    public function load (int $id) {
372
        $statement = $this->cache(__FUNCTION__, function() {
373
            return $this->select()->where('id = ?')->prepare();
374
        });
375
        $values = $statement([$id])->fetch();
376
        $statement->closeCursor();
377
        if ($values) {
378
            $entity = clone $this->proto;
379
            $this->setValues($entity, $values);
380
            $this->loadEav([$id => $entity]);
381
            return $entity;
382
        }
383
        return null;
384
    }
385
386
    /**
387
     * Returns a {@link Select} that fetches instances.
388
     *
389
     * @return Select|EntityInterface[]
390
     */
391
    public function loadAll () {
392
        return $this->select()->setFetcher(function(Statement $statement) {
393
            yield from $this->fetchEach($statement);
394
        });
395
    }
396
397
    /**
398
     * Loads and sets all EAV properties for an array of entities keyed by ID.
399
     *
400
     * @param EntityInterface[] $entities
401
     */
402
    protected function loadEav (array $entities): void {
403
        $ids = array_keys($entities);
404
        foreach ($this->eav as $name => $eav) {
405
            foreach ($eav->loadAll($ids) as $id => $values) {
406
                $this->properties[$name]->setValue($entities[$id], $values);
407
            }
408
        }
409
    }
410
411
    /**
412
     * Upserts record and EAV data.
413
     *
414
     * @param EntityInterface $entity
415
     * @return int ID
416
     */
417
    public function save (EntityInterface $entity): int {
418
        if (!$entity->getId()) {
419
            $this->saveInsert($entity);
420
        }
421
        else {
422
            $this->saveUpdate($entity);
423
        }
424
        $this->saveEav($entity);
425
        return $entity->getId();
426
    }
427
428
    /**
429
     * @param EntityInterface $entity
430
     */
431
    protected function saveEav (EntityInterface $entity): void {
432
        $id = $entity->getId();
433
        foreach ($this->eav as $name => $eav) {
434
            // may be null to skip
435
            $values = $this->properties[$name]->getValue($entity);
436
            if (isset($values)) {
437
                $eav->save($id, $values);
438
            }
439
        }
440
    }
441
442
    /**
443
     * Inserts a new row and updates the entity's ID.
444
     *
445
     * @param EntityInterface $entity
446
     */
447
    protected function saveInsert (EntityInterface $entity): void {
448
        $statement = $this->cache(__FUNCTION__, function() {
449
            $slots = $this->db->slots(array_keys($this->columns));
450
            unset($slots['id']);
451
            $columns = implode(',', array_keys($slots));
452
            $slots = implode(',', $slots);
453
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
454
        });
455
        $values = $this->getValues($entity);
456
        unset($values['id']);
457
        $this->properties['id']->setValue($entity, $statement($values)->getId());
458
        $statement->closeCursor();
459
    }
460
461
    /**
462
     * Updates the existing row for the entity.
463
     *
464
     * @param EntityInterface $entity
465
     */
466
    protected function saveUpdate (EntityInterface $entity): void {
467
        $statement = $this->cache(__FUNCTION__, function() {
468
            $slots = $this->db->slotsEqual(array_keys($this->columns));
469
            unset($slots['id']);
470
            $slots = implode(', ', $slots);
471
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
472
        });
473
        $statement->execute($this->getValues($entity));
474
        $statement->closeCursor();
475
    }
476
477
    /**
478
     * @param EntityInterface $proto
479
     * @return $this
480
     */
481
    public function setProto (EntityInterface $proto) {
482
        $this->proto = $proto;
483
        return $this;
484
    }
485
486
    /**
487
     * Converts a value from storage into the native/annotated type.
488
     *
489
     * @param string $property
490
     * @param mixed $value
491
     * @return mixed
492
     */
493
    protected function setType (string $property, $value) {
494
        if (isset($value)) {
495
            // complex?
496
            if (isset($this->hydration[$property])) {
497
                $to = $this->hydration[$property];
498
                $from = static::DEHYDRATE_AS[$to];
499
                return $this->setType_hydrate($to, $from, $value);
500
            }
501
            // scalar. this function doesn't care about the type's letter case.
502
            settype($value, $this->types[$property]);
503
        }
504
        return $value;
505
    }
506
507
    /**
508
     * Hydrates a complex value from scalar storage.
509
     *
510
     * @see Record::getValues_dehydrate() inverse
511
     *
512
     * @param string $to The strict type from the class definition.
513
     * @param string $from The storage type.
514
     * @param scalar $dehydrated
515
     * @return array|object
516
     */
517
    protected function setType_hydrate (string $to, string $from, $dehydrated) {
518
        switch ($from) {
519
            case 'DateTime':
520
                /**
521
                 * $to might be "DateTime", "DateTimeImmutable", or an extension.
522
                 *
523
                 * @see DateTime::createFromFormat()
524
                 */
525
                return call_user_func(
526
                    [$to, 'createFromFormat'],
527
                    'Y-m-d H:i:s',
528
                    $dehydrated,
529
                    $this->utc
530
                );
531
            default:
532
                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

532
                return unserialize(/** @scrutinizer ignore-type */ $dehydrated);
Loading history...
533
        }
534
    }
535
536
    /**
537
     * @param EntityInterface $entity
538
     * @param array $values
539
     */
540
    protected function setValues (EntityInterface $entity, array $values): void {
541
        foreach ($values as $name => $value) {
542
            if (isset($this->properties[$name])) {
543
                $this->properties[$name]->setValue($entity, $this->setType($name, $value));
544
            }
545
            else {
546
                // attempt to set unknown fields directly on the instance.
547
                $entity->{$name} = $value;
548
            }
549
        }
550
    }
551
}