Passed
Push — master ( a9c35a...4898bc )
by y
01:59
created

Record   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 529
Duplicated Lines 0 %

Importance

Changes 9
Bugs 0 Features 1
Metric Value
eloc 161
c 9
b 0
f 1
dl 0
loc 529
rs 4.08
wmc 59

27 Methods

Rating   Name   Duplication   Size   Complexity  
A save() 0 9 2
A setType() 0 13 3
A getEav() 0 3 1
A saveInsert() 0 13 1
A getValues_dehydrate() 0 17 4
A __construct() 0 27 5
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 13 2
A getUnique() 0 3 1
A getUniqueGroup() 0 8 4
A findAll() 0 11 3
A saveEav() 0 8 3
A getValues() 0 13 3
A setValues() 0 9 3
A setType_hydrate() 0 26 4
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 Helix\DB\Fluent\Predicate;
11
use stdClass;
12
13
/**
14
 * Represents an "active record" table, derived from an annotated class implementing {@link EntityInterface}.
15
 *
16
 * Class Annotations:
17
 *
18
 * - `@record TABLE`
19
 *
20
 * Property Annotations:
21
 *
22
 * - `@col` or `@column`
23
 * - `@unique` or `@unique <SHARED_IDENTIFIER>` for a single or multi-column unique-key.
24
 *  - The shared identifier must be alphabetical, allowing underscores.
25
 *  - The identifier can be arbitrary, but it's necessary in order to associate component properties.
26
 *  - The column/s may be nullable; MySQL and SQLite don't enforce uniqueness for NULL.
27
 * - `@eav <TABLE>`
28
 *
29
 * Property types are preserved.
30
 * Properties which are objects can be dehydrated/rehydrated if they're strictly typed.
31
 * Strict typing is preferred, but annotations and finally default values are used as fallbacks.
32
 *
33
 * > Annotating the types `String` (capital "S") or `STRING` (all caps) results in `TEXT` and `BLOB`
34
 *
35
 * @method static static factory(DB $db, string|EntityInterface $class)
36
 */
37
class Record extends Table
38
{
39
40
    /**
41
     * Maps complex types to storage types.
42
     *
43
     * {@link EntityInterface} is always dehydrated as the integer ID.
44
     *
45
     * @see Schema::T_CONST_NAMES keys
46
     */
47
    protected const DEHYDRATE_AS = [
48
        'array' => 'STRING', // blob. eav is better than this for 1D arrays.
49
        'object' => 'STRING', // blob.
50
        stdClass::class => 'STRING', // blob
51
        DateTime::class => 'DateTime',
52
        DateTimeImmutable::class => 'DateTime',
53
    ];
54
55
    /**
56
     * The number of entities to load EAV entries for at a time,
57
     * during {@link Record::fetchEach()} iteration.
58
     */
59
    protected const EAV_BATCH_LOAD = 256;
60
61
    /**
62
     * `[property => EAV]`
63
     *
64
     * @var EAV[]
65
     */
66
    protected $eav = [];
67
68
    /**
69
     * The specific classes used to hydrate classed properties, like `DateTime`.
70
     *
71
     * `[ property => class ]`
72
     *
73
     * @var string[]
74
     */
75
    protected $hydration = [];
76
77
    /**
78
     * `[ property => is nullable ]`
79
     *
80
     * @var bool[]
81
     */
82
    protected $nullable = [];
83
84
    /**
85
     * A boilerplate instance of the class, to clone and populate.
86
     *
87
     * @var EntityInterface
88
     */
89
    protected $proto;
90
91
    /**
92
     * @var Reflection
93
     */
94
    protected $ref;
95
96
    /**
97
     * Storage types.
98
     *
99
     * `[property => type]`
100
     *
101
     * @var string[]
102
     */
103
    protected $types = [];
104
105
    /**
106
     * @var array
107
     */
108
    protected $unique;
109
110
    /**
111
     * @var DateTimeZone
112
     */
113
    protected DateTimeZone $utc;
114
115
    /**
116
     * @param DB $db
117
     * @param string|EntityInterface $class
118
     */
119
    public function __construct(DB $db, $class)
120
    {
121
        $this->ref = Reflection::factory($db, $class);
122
        $this->proto = is_object($class) ? $class : $this->ref->newProto();
123
        assert($this->proto instanceof EntityInterface);
124
        $this->unique = $this->ref->getUnique();
125
        $this->utc = new DateTimeZone('UTC');
126
127
        // TODO allow aliasing
128
        $cols = $this->ref->getColumns();
129
        foreach ($cols as $col) {
130
            $type = $this->ref->getType($col);
131
            if (isset(static::DEHYDRATE_AS[$type])) {
132
                $this->hydration[$col] = $type;
133
                $type = static::DEHYDRATE_AS[$type];
134
            } elseif (is_a($type, EntityInterface::class, true)) {
135
                $this->hydration[$col] = $type;
136
                $type = 'int';
137
            }
138
            $this->types[$col] = $type;
139
            $this->nullable[$col] = $this->ref->isNullable($col);
140
        }
141
        $this->types['id'] = 'int';
142
        $this->nullable['id'] = false;
143
        $this->eav = $this->ref->getEav();
144
145
        parent::__construct($db, $this->ref->getRecordTable(), $cols);
146
    }
147
148
    /**
149
     * Fetches from a statement into clones of the entity prototype.
150
     *
151
     * @param Statement $statement
152
     * @return EntityInterface[] Keyed by ID
153
     */
154
    public function fetchAll(Statement $statement): array
155
    {
156
        return iterator_to_array($this->fetchEach($statement));
157
    }
158
159
    /**
160
     * Fetches in chunks and yields each loaded entity.
161
     * This is preferable over {@link fetchAll()} for iterating large result sets.
162
     *
163
     * @param Statement $statement
164
     * @return Generator|EntityInterface[] Keyed by ID
165
     */
166
    public function fetchEach(Statement $statement)
167
    {
168
        do {
169
            $entities = [];
170
            for ($i = 0; $i < static::EAV_BATCH_LOAD and false !== $row = $statement->fetch(); $i++) {
171
                $clone = clone $this->proto;
172
                $this->setValues($clone, $row);
173
                $entities[$row['id']] = $clone;
174
            }
175
            $this->loadEav($entities);
176
            yield from $entities;
177
        } while (!empty($entities));
178
    }
179
180
    /**
181
     * Similar to {@link loadAll()} except this can additionally search by {@link EAV} values.
182
     *
183
     * @see Predicate::match()
184
     *
185
     * @param array $match `[property => value]`
186
     * @param array[] $eavMatch `[eav property => attribute => value]`
187
     * @return Select|EntityInterface[]
188
     */
189
    public function findAll(array $match, array $eavMatch = [])
190
    {
191
        $select = $this->loadAll();
192
        foreach ($match as $a => $b) {
193
            $select->where(Predicate::match($this->db, $this[$a] ?? $a, $b));
194
        }
195
        foreach ($eavMatch as $property => $attributes) {
196
            $inner = $this->eav[$property]->findAll($attributes);
197
            $select->join($inner, $inner['entity']->isEqual($this['id']));
198
        }
199
        return $select;
200
    }
201
202
    /**
203
     * Returns an instance for the first row matching the criteria.
204
     *
205
     * @param array $match `[property => value]`
206
     * @param array $eavMatch `[eav property => attribute => value]`
207
     * @return null|EntityInterface
208
     */
209
    public function findFirst(array $match, array $eavMatch = [])
210
    {
211
        return $this->findAll($match, $eavMatch)->limit(1)->getFirst();
212
    }
213
214
    /**
215
     * @return string
216
     */
217
    final public function getClass(): string
218
    {
219
        return get_class($this->proto);
220
    }
221
222
    /**
223
     * @return EAV[]
224
     */
225
    public function getEav()
226
    {
227
        return $this->eav;
228
    }
229
230
    /**
231
     * @return EntityInterface
232
     */
233
    public function getProto()
234
    {
235
        return $this->proto;
236
    }
237
238
    /**
239
     * Returns a native/annotated property type.
240
     *
241
     * This doesn't include whether the property is nullable. Use {@link Record::isNullable()} for that.
242
     *
243
     * @param string $property
244
     * @return string
245
     */
246
    final public function getType(string $property): string
247
    {
248
        return $this->types[$property];
249
    }
250
251
    /**
252
     * Returns the native/annotated property types.
253
     *
254
     * This doesn't include whether the properties are nullable. Use {@link Record::isNullable()} for that.
255
     *
256
     * @return string[]
257
     */
258
    final public function getTypes(): array
259
    {
260
        return $this->types;
261
    }
262
263
    /**
264
     * @return array
265
     */
266
    final public function getUnique(): array
267
    {
268
        return $this->unique;
269
    }
270
271
    /**
272
     * The shared identifier if a property is part of a multi-column unique-key.
273
     *
274
     * @param string $property
275
     * @return null|string The shared identifier, or nothing.
276
     */
277
    final public function getUniqueGroup(string $property): ?string
278
    {
279
        foreach ($this->unique as $key => $value) {
280
            if (is_string($key) and in_array($property, $value)) {
281
                return $key;
282
            }
283
        }
284
        return null;
285
    }
286
287
    /**
288
     * @param EntityInterface $entity
289
     * @return array
290
     */
291
    protected function getValues(EntityInterface $entity): array
292
    {
293
        $values = [];
294
        foreach (array_keys($this->columns) as $prop) {
295
            $value = $this->ref->getValue($entity, $prop);
296
            if (isset($value, $this->hydration[$prop])) {
297
                $from = $this->hydration[$prop];
298
                $to = static::DEHYDRATE_AS[$from];
299
                $value = $this->getValues_dehydrate($to, $from, $value);
300
            }
301
            $values[$prop] = $value;
302
        }
303
        return $values;
304
    }
305
306
    /**
307
     * Dehydrates a complex property's value for storage in a scalar column.
308
     *
309
     * @see Record::setType_hydrate() inverse
310
     *
311
     * @param string $to The storage type.
312
     * @param string $from The strict type from the class definition.
313
     * @param array|object $hydrated
314
     * @return null|scalar
315
     */
316
    protected function getValues_dehydrate(string $to, string $from, $hydrated)
317
    {
318
        // we don't need $from here but it's given for posterity
319
        unset($from);
320
321
        // dehydrate entities to their id
322
        if ($hydrated instanceof EntityInterface) {
323
            return $hydrated->getId();
324
        }
325
326
        // dehydrate other complex types
327
        switch ($to) {
328
            case 'DateTime':
329
                assert($hydrated instanceof DateTime or $hydrated instanceof DateTimeImmutable);
330
                return (clone $hydrated)->setTimezone($this->utc)->format(Schema::DATETIME_FORMAT);
331
            default:
332
                return serialize($hydrated);
333
        }
334
    }
335
336
    /**
337
     * @param string $property
338
     * @return bool
339
     */
340
    final public function isNullable(string $property): bool
341
    {
342
        return $this->nullable[$property];
343
    }
344
345
    /**
346
     * Whether a property has a unique-key constraint of its own.
347
     *
348
     * @param string $property
349
     * @return bool
350
     */
351
    final public function isUnique(string $property): bool
352
    {
353
        return in_array($property, $this->unique);
354
    }
355
356
    /**
357
     * Loads all data for a given ID (clones the prototype), or an existing instance.
358
     *
359
     * @param int|EntityInterface $id The given instance may be a subclass of the prototype.
360
     * @return null|EntityInterface
361
     */
362
    public function load($id)
363
    {
364
        $statement = $this->cache(__FUNCTION__, function () {
365
            return $this->select()->where('id = ?')->prepare();
366
        });
367
        if ($id instanceof EntityInterface) {
368
            assert(is_a($id, get_class($this->proto)));
369
            $entity = $id;
370
            $id = $entity->getId();
371
        } else {
372
            $entity = clone $this->proto;
373
        }
374
        $values = $statement([$id])->fetch();
375
        $statement->closeCursor();
376
        if ($values) {
377
            $this->setValues($entity, $values);
378
            $this->loadEav([$id => $entity]);
379
            return $entity;
380
        }
381
        return null;
382
    }
383
384
    /**
385
     * Returns a {@link Select} that fetches instances.
386
     *
387
     * @return Select|EntityInterface[]
388
     */
389
    public function loadAll()
390
    {
391
        return $this->select()->setFetcher(function (Statement $statement) {
392
            yield from $this->fetchEach($statement);
393
        });
394
    }
395
396
    /**
397
     * Loads and sets all EAV properties for an array of entities keyed by ID.
398
     *
399
     * @param EntityInterface[] $entities Keyed by ID
400
     */
401
    protected function loadEav(array $entities): void
402
    {
403
        $ids = array_keys($entities);
404
        foreach ($this->eav as $attr => $eav) {
405
            foreach ($eav->loadAll($ids) as $id => $values) {
406
                $this->ref->setValue($entities[$id], $attr, $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
    {
419
        if (!$entity->getId()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $entity->getId() of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
420
            $this->saveInsert($entity);
421
        } else {
422
            $this->saveUpdate($entity);
423
        }
424
        $this->saveEav($entity);
425
        return $entity->getId();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $entity->getId() could return the type null which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
426
    }
427
428
    /**
429
     * @param EntityInterface $entity
430
     */
431
    protected function saveEav(EntityInterface $entity): void
432
    {
433
        $id = $entity->getId();
434
        foreach ($this->eav as $attr => $eav) {
435
            $values = $this->ref->getValue($entity, $attr);
436
            // skip if null
437
            if (isset($values)) {
438
                $eav->save($id, $values);
0 ignored issues
show
Bug introduced by
It seems like $id can also be of type null; however, parameter $id of Helix\DB\EAV::save() does only seem to accept integer, 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

438
                $eav->save(/** @scrutinizer ignore-type */ $id, $values);
Loading history...
439
            }
440
        }
441
    }
442
443
    /**
444
     * Inserts a new row and updates the entity's ID.
445
     *
446
     * @param EntityInterface $entity
447
     */
448
    protected function saveInsert(EntityInterface $entity): void
449
    {
450
        $statement = $this->cache(__FUNCTION__, function () {
451
            $slots = $this->db->slots(array_keys($this->columns));
452
            unset($slots['id']);
453
            $columns = implode(',', array_keys($slots));
454
            $slots = implode(',', $slots);
455
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
456
        });
457
        $values = $this->getValues($entity);
458
        unset($values['id']);
459
        $this->ref->setValue($entity, 'id', $statement($values)->getId());
460
        $statement->closeCursor();
461
    }
462
463
    /**
464
     * Updates the existing row for the entity.
465
     *
466
     * @param EntityInterface $entity
467
     */
468
    protected function saveUpdate(EntityInterface $entity): void
469
    {
470
        $statement = $this->cache(__FUNCTION__, function () {
471
            $slots = $this->db->slots(array_keys($this->columns));
472
            foreach ($slots as $column => $slot) {
473
                $slots[$column] = "{$column} = {$slot}";
474
            }
475
            unset($slots['id']);
476
            $slots = implode(', ', $slots);
477
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
478
        });
479
        $statement->execute($this->getValues($entity));
480
        $statement->closeCursor();
481
    }
482
483
    /**
484
     * @param EntityInterface $proto
485
     * @return $this
486
     */
487
    public function setProto(EntityInterface $proto)
488
    {
489
        $this->proto = $proto;
490
        return $this;
491
    }
492
493
    /**
494
     * Converts a value from storage into the native/annotated type.
495
     *
496
     * @param string $property
497
     * @param mixed $value
498
     * @return mixed
499
     */
500
    protected function setType(string $property, $value)
501
    {
502
        if (isset($value)) {
503
            // complex?
504
            if (isset($this->hydration[$property])) {
505
                $to = $this->hydration[$property];
506
                $from = static::DEHYDRATE_AS[$to];
507
                return $this->setType_hydrate($to, $from, $value);
508
            }
509
            // scalar. this function doesn't care about the type's letter case.
510
            settype($value, $this->types[$property]);
511
        }
512
        return $value;
513
    }
514
515
    /**
516
     * Hydrates a complex value from scalar storage.
517
     *
518
     * @see Record::getValues_dehydrate() inverse
519
     *
520
     * @param string $to The strict type from the class definition.
521
     * @param string $from The storage type.
522
     * @param scalar $dehydrated
523
     * @return array|object
524
     */
525
    protected function setType_hydrate(string $to, string $from, $dehydrated)
526
    {
527
        // hydrate DateTime
528
        if ($from === 'DateTime') {
529
            /**
530
             * $to might be "DateTime", "DateTimeImmutable", or an extension.
531
             *
532
             * @see DateTime::createFromFormat()
533
             */
534
            return call_user_func(
535
                [$to, 'createFromFormat'],
536
                'Y-m-d H:i:s',
537
                $dehydrated,
538
                $this->utc
539
            );
540
        }
541
542
        // hydrate entities
543
        if (is_a($to, EntityInterface::class, true)) {
544
            return $this->db->getRecord($to)->load($dehydrated);
0 ignored issues
show
Bug introduced by
It seems like $dehydrated can also be of type boolean and double and string; however, parameter $id of Helix\DB\Record::load() does only seem to accept Helix\DB\EntityInterface|integer, 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

544
            return $this->db->getRecord($to)->load(/** @scrutinizer ignore-type */ $dehydrated);
Loading history...
545
        }
546
547
        // hydrate other complex
548
        $complex = 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

548
        $complex = unserialize(/** @scrutinizer ignore-type */ $dehydrated);
Loading history...
549
        assert(is_array($complex) or is_object($complex));
550
        return $complex;
551
    }
552
553
    /**
554
     * @param EntityInterface $entity
555
     * @param array $values
556
     */
557
    protected function setValues(EntityInterface $entity, array $values): void
558
    {
559
        foreach ($values as $prop => $value) {
560
            if (isset($this->columns[$prop])) {
561
                $value = $this->setType($prop, $value);
562
                $this->ref->setValue($entity, $prop, $value);
563
            } else {
564
                // attempt to set unknown fields directly on the instance.
565
                $entity->{$prop} = $value;
566
            }
567
        }
568
    }
569
}
570