Passed
Push — master ( b3ece8...6db175 )
by y
01:34
created

Record   C

Complexity

Total Complexity 54

Size/Duplication

Total Lines 505
Duplicated Lines 0 %

Importance

Changes 7
Bugs 0 Features 1
Metric Value
eloc 152
c 7
b 0
f 1
dl 0
loc 505
rs 6.4799
wmc 54

27 Methods

Rating   Name   Duplication   Size   Complexity  
A setType() 0 13 3
A getEav() 0 3 1
A saveInsert() 0 13 1
A getValues_dehydrate() 0 9 3
A __construct() 0 24 4
A getClass() 0 3 1
A isNullable() 0 3 1
A setProto() 0 4 1
A load() 0 20 3
A getProto() 0 3 1
A fetchAll() 0 3 1
A saveUpdate() 0 10 1
A getUnique() 0 3 1
A getUniqueGroup() 0 8 4
A findAll() 0 11 3
A save() 0 9 2
A saveEav() 0 8 3
A getValues() 0 13 3
A setValues() 0 9 3
A setType_hydrate() 0 17 2
A getType() 0 3 1
A fetchEach() 0 12 4
A loadAll() 0 4 1
A findFirst() 0 3 1
A loadEav() 0 6 3
A getTypes() 0 3 1
A isUnique() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Record often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Record, and based on these observations, apply Extract Interface, too.

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 stdClass;
11
12
/**
13
 * Represents an "active record" table, derived from an annotated class implementing {@link EntityInterface}.
14
 *
15
 * Class Annotations:
16
 *
17
 * - `@record TABLE`
18
 *
19
 * Property Annotations:
20
 *
21
 * - `@col` or `@column`
22
 * - `@unique` or `@unique <SHARED_IDENTIFIER>` for a single or multi-column unique-key.
23
 *  - The shared identifier must be alphabetical, allowing underscores.
24
 *  - The identifier can be arbitrary, but it's necessary in order to associate component properties.
25
 *  - The column/s may be nullable; MySQL and SQLite don't enforce uniqueness for NULL.
26
 * - `@eav <TABLE>`
27
 *
28
 * Property types are preserved.
29
 * Properties which are objects can be dehydrated/rehydrated if they're strictly typed.
30
 * Strict typing is preferred, but annotations and finally default values are used as fallbacks.
31
 *
32
 * > Annotating the types `String` (capital "S") or `STRING` (all caps) results in `TEXT` and `BLOB`
33
 *
34
 * @method static static factory(DB $db, string|EntityInterface $class)
35
 *
36
 * @TODO Auto-map singular foreign entity columns.
37
 */
38
class Record extends Table
39
{
40
41
    /**
42
     * Maps complex types to storage types.
43
     *
44
     * @see Schema::T_CONST_NAMES keys
45
     */
46
    protected const DEHYDRATE_AS = [
47
        'array' => 'STRING', // blob. eav is better than this for 1D arrays.
48
        'object' => 'STRING', // blob.
49
        stdClass::class => 'STRING', // blob
50
        DateTime::class => 'DateTime',
51
        DateTimeImmutable::class => 'DateTime',
52
    ];
53
54
    /**
55
     * The number of entities to load EAV entries for at a time,
56
     * during {@link Record::fetchEach()} iteration.
57
     */
58
    protected const EAV_BATCH_LOAD = 256;
59
60
    /**
61
     * `[property => EAV]`
62
     *
63
     * @var EAV[]
64
     */
65
    protected $eav = [];
66
67
    /**
68
     * The specific classes used to hydrate classed properties, like `DateTime`.
69
     *
70
     * `[ property => class ]`
71
     *
72
     * @var string[]
73
     */
74
    protected $hydration = [];
75
76
    /**
77
     * `[ property => is nullable ]`
78
     *
79
     * @var bool[]
80
     */
81
    protected $nullable = [];
82
83
    /**
84
     * A boilerplate instance of the class, to clone and populate.
85
     *
86
     * @var EntityInterface
87
     */
88
    protected $proto;
89
90
    /**
91
     * @var Reflection
92
     */
93
    protected $ref;
94
95
    /**
96
     * Storage types.
97
     *
98
     * `[property => type]`
99
     *
100
     * @var string[]
101
     */
102
    protected $types = [];
103
104
    /**
105
     * @var array
106
     */
107
    protected $unique;
108
109
    /**
110
     * @var DateTimeZone
111
     */
112
    protected DateTimeZone $utc;
113
114
    /**
115
     * @param DB $db
116
     * @param string|EntityInterface $class
117
     */
118
    public function __construct(DB $db, $class)
119
    {
120
        $this->ref = Reflection::factory($db, $class);
121
        $this->proto = is_object($class) ? $class : $this->ref->newProto();
122
        assert($this->proto instanceof EntityInterface);
123
        $this->unique = $this->ref->getUnique();
124
        $this->utc = new DateTimeZone('UTC');
125
126
        // TODO allow aliasing
127
        $cols = $this->ref->getColumns();
128
        foreach ($cols as $col) {
129
            $type = $this->ref->getType($col);
130
            if (isset(static::DEHYDRATE_AS[$type])) {
131
                $this->hydration[$col] = $type;
132
                $type = static::DEHYDRATE_AS[$type];
133
            }
134
            $this->types[$col] = $type;
135
            $this->nullable[$col] = $this->ref->isNullable($col);
136
        }
137
        $this->types['id'] = 'int';
138
        $this->nullable['id'] = false;
139
        $this->eav = $this->ref->getEav();
140
141
        parent::__construct($db, $this->ref->getRecordTable(), $cols);
142
    }
143
144
    /**
145
     * Fetches from a statement into clones of the entity prototype.
146
     *
147
     * @param Statement $statement
148
     * @return EntityInterface[] Keyed by ID
149
     */
150
    public function fetchAll(Statement $statement): array
151
    {
152
        return iterator_to_array($this->fetchEach($statement));
153
    }
154
155
    /**
156
     * Fetches in chunks and yields each loaded entity.
157
     * This is preferable over {@link fetchAll()} for iterating large result sets.
158
     *
159
     * @param Statement $statement
160
     * @return Generator|EntityInterface[] Keyed by ID
161
     */
162
    public function fetchEach(Statement $statement)
163
    {
164
        do {
165
            $entities = [];
166
            for ($i = 0; $i < static::EAV_BATCH_LOAD and false !== $row = $statement->fetch(); $i++) {
167
                $clone = clone $this->proto;
168
                $this->setValues($clone, $row);
169
                $entities[$row['id']] = $clone;
170
            }
171
            $this->loadEav($entities);
172
            yield from $entities;
173
        } while (!empty($entities));
174
    }
175
176
    /**
177
     * Similar to {@link loadAll()} except this can additionally search by {@link EAV} values.
178
     *
179
     * @see DB::match()
180
     *
181
     * @param array $match `[property => value]`
182
     * @param array[] $eavMatch `[eav property => attribute => value]`
183
     * @return Select|EntityInterface[]
184
     */
185
    public function findAll(array $match, array $eavMatch = [])
186
    {
187
        $select = $this->loadAll();
188
        foreach ($match as $a => $b) {
189
            $select->where($this->db->match($this[$a] ?? $a, $b));
190
        }
191
        foreach ($eavMatch as $property => $attributes) {
192
            $inner = $this->eav[$property]->findAll($attributes);
193
            $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

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

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