Passed
Push — master ( 6df7c9...74bc2a )
by y
02:15
created

Record   A

Complexity

Total Complexity 41

Size/Duplication

Total Lines 305
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 41
eloc 113
c 4
b 0
f 0
dl 0
loc 305
rs 9.1199

17 Methods

Rating   Name   Duplication   Size   Complexity  
A getAll() 0 2 1
A getEach() 0 12 4
A fromClass() 0 24 6
A getEav() 0 2 1
A saveInsert() 0 11 1
A __construct() 0 24 5
A setProto() 0 3 1
A load() 0 11 2
A getProto() 0 2 1
A saveUpdate() 0 7 1
A save() 0 9 2
A saveEav() 0 6 3
A select() 0 8 2
A setValues() 0 4 2
A getValues() 0 6 2
A loadEav() 0 6 4
A find() 0 10 3

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 Generator;
6
use Helix\DB;
7
use LogicException;
8
use ReflectionClass;
9
use ReflectionException;
10
use ReflectionProperty;
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
 * - `@eav TABLE`
23
 *
24
 * Property value types are preserved as long as they are annotated with `@var`.
25
 */
26
class Record extends Table {
27
28
    /**
29
     * `[property => EAV]`
30
     *
31
     * @var EAV[]
32
     */
33
    protected $eav = [];
34
35
    /**
36
     * `[property => ReflectionProperty]`
37
     *
38
     * @var ReflectionProperty[]
39
     */
40
    protected $properties = [];
41
42
    /**
43
     * A boilerplate instance of the class, to clone and populate.
44
     * This defaults to a naively created instance without invoking the constructor.
45
     *
46
     * @var EntityInterface
47
     */
48
    protected $proto;
49
50
    /**
51
     * `[property => type]`
52
     *
53
     * @var array
54
     */
55
    protected $types = [];
56
57
    /**
58
     * @param DB $db
59
     * @param string|EntityInterface $class
60
     * @return Record
61
     */
62
    public static function fromClass (DB $db, $class) {
63
        try {
64
            $rClass = new ReflectionClass($class);
65
        }
66
        catch (ReflectionException $exception) {
67
            throw new LogicException('Unexpected ReflectionException', 0, $exception);
68
        }
69
        $columns = [];
70
        $eav = [];
71
        foreach ($rClass->getProperties() as $rProp) {
72
            $doc = $rProp->getDocComment();
73
            $name = $rProp->getName();
74
            if (preg_match('/@col(umn)?[\s$]/', $doc)) {
75
                $columns[] = $name;
76
            }
77
            elseif (preg_match('/@eav\s+(?<table>\S+)/', $doc, $attr)) {
78
                $eav[$name] = new EAV($db, $attr['table']);
79
            }
80
        }
81
        preg_match('/@record\s+(?<table>\S+)/', $rClass->getDocComment(), $record);
82
        if (!is_object($class)) {
83
            $class = $rClass->newInstanceWithoutConstructor();
84
        }
85
        return new static($db, $class, $record['table'], $columns, $eav);
86
    }
87
88
    /**
89
     * @param DB $db
90
     * @param EntityInterface $proto
91
     * @param string $table
92
     * @param string[] $columns Property names.
93
     * @param EAV[] $eav Keyed by property name.
94
     */
95
    public function __construct (DB $db, EntityInterface $proto, string $table, array $columns, array $eav = []) {
96
        parent::__construct($db, $table, $columns);
97
        $this->proto = $proto;
98
        try {
99
            $rClass = new ReflectionClass($proto);
100
            foreach ($columns as $name) {
101
                $rProp = $rClass->getProperty($name);
102
                $rProp->setAccessible(true);
103
                $this->properties[$name] = $rProp;
104
                $this->types[$name] = 'string';
105
                if (preg_match('/@var\s+(?<type>[a-z]+)[\s$]/', $rProp->getDocComment(), $var)) {
106
                    $types[$name] = $var['type'];
107
                }
108
            }
109
            $this->types['id'] = 'int';
110
            $this->eav = $eav;
111
            foreach (array_keys($eav) as $name) {
112
                $rProp = $rClass->getProperty($name);
113
                $rProp->setAccessible(true);
114
                $this->properties[$name] = $rProp;
115
            }
116
        }
117
        catch (ReflectionException $exception) {
118
            throw new LogicException('Unexpected ReflectionException', 0, $exception);
119
        }
120
    }
121
122
    /**
123
     * Returns a {@link Select}.
124
     *
125
     * @see DB::match()
126
     *
127
     * @param array $match `[property => mixed]`
128
     * @param array[] $eavMatch `[eav property => attribute => mixed]`
129
     * @return Select
130
     */
131
    public function find (array $match, array $eavMatch = []) {
132
        $select = $this->select();
133
        foreach ($match as $a => $b) {
134
            $select->where($this->db->match($this[$a] ?? $a, $b));
135
        }
136
        foreach ($eavMatch as $property => $attributes) {
137
            $inner = $this->getEav($property)->find($attributes);
138
            $select->join($inner, $inner['entity']->isEqual($this['id']));
139
        }
140
        return $select;
141
    }
142
143
    /**
144
     * Fetches from a statement into clones of the entity prototype.
145
     *
146
     * @param Statement $statement
147
     * @return EntityInterface[] Enumerated
148
     */
149
    public function getAll (Statement $statement): array {
150
        return iterator_to_array($this->getEach($statement));
151
    }
152
153
    /**
154
     * Fetches in chunks and yields each loaded entity.
155
     * This is preferable over `fetchAll()` for iterating large result sets.
156
     *
157
     * @param Statement $statement
158
     * @return Generator
159
     */
160
    public function getEach (Statement $statement) {
161
        do {
162
            $entities = [];
163
            for ($i = 0; $i < 256 and false !== $row = $statement->fetch(); $i++) {
164
                $clone = clone $this->proto;
165
                $this->setValues($clone, $row);
166
                $entities[$row['id']] = $clone;
167
            }
168
            $this->loadEav($entities);
169
            yield from $entities;
170
        }
171
        while (!empty($entities));
172
    }
173
174
    /**
175
     * @param string $property
176
     * @return EAV
177
     */
178
    final public function getEav (string $property) {
179
        return $this->eav[$property];
180
    }
181
182
    /**
183
     * @return EntityInterface
184
     */
185
    public function getProto () {
186
        return $this->proto;
187
    }
188
189
    /**
190
     * @param EntityInterface $entity
191
     * @return array
192
     */
193
    protected function getValues (EntityInterface $entity): array {
194
        $values = [];
195
        foreach (array_keys($this->columns) as $name) {
196
            $values[$name] = $this->properties[$name]->getValue($entity);
197
        }
198
        return $values;
199
    }
200
201
    /**
202
     * Loads all data for a given ID into a clone of the prototype.
203
     *
204
     * @param int $id
205
     * @return null|EntityInterface
206
     */
207
    public function load (int $id) {
208
        $load = $this->cache(__FUNCTION__, function() {
209
            return $this->select(array_keys($this->columns))->where('id = ?')->prepare();
210
        });
211
        if ($values = $load([$id])->fetch()) {
212
            $entity = clone $this->proto;
213
            $this->setValues($entity, $values);
214
            $this->loadEav([$id => $entity]);
215
            return $entity;
216
        }
217
        return null;
218
    }
219
220
    /**
221
     * Loads and sets all EAV properties for an array of entities keyed by ID.
222
     *
223
     * @param EntityInterface[] $entities
224
     */
225
    protected function loadEav (array $entities): void {
226
        $ids = array_keys($entities);
227
        foreach ($this->eav as $name => $eav) {
228
            foreach ($eav->loadAll($ids) as $id => $values) {
229
                if (!empty($values)) {
230
                    $this->properties[$name]->setValue($entities[$id], $values);
231
                }
232
            }
233
        }
234
    }
235
236
    /**
237
     * Upserts record and EAV data.
238
     *
239
     * @param EntityInterface $entity
240
     * @return int ID
241
     */
242
    public function save (EntityInterface $entity): int {
243
        if (!$entity->getId()) {
244
            $this->saveInsert($entity);
245
        }
246
        else {
247
            $this->saveUpdate($entity);
248
        }
249
        $this->saveEav($entity);
250
        return $entity->getId();
251
    }
252
253
    /**
254
     * @param EntityInterface $entity
255
     */
256
    protected function saveEav (EntityInterface $entity): void {
257
        $id = $entity->getId();
258
        foreach ($this->eav as $name => $eav) {
259
            $values = $this->properties[$name]->getValue($entity);
260
            if (isset($values)) {
261
                $eav->save($id, $values);
262
            }
263
        }
264
    }
265
266
    /**
267
     * Inserts a new row and updates the entity's ID.
268
     *
269
     * @param EntityInterface $entity
270
     */
271
    protected function saveInsert (EntityInterface $entity): void {
272
        $insert = $this->cache(__FUNCTION__, function() {
273
            $slots = SQL::slots(array_keys($this->columns));
274
            unset($slots['id']);
275
            $columns = implode(',', array_keys($slots));
276
            $slots = implode(',', $slots);
277
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
278
        });
279
        $values = $this->getValues($entity);
280
        unset($values['id']);
281
        $this->properties['id']->setValue($entity, $insert($values)->getId());
282
    }
283
284
    /**
285
     * Updates the existing row for the entity.
286
     *
287
     * @param EntityInterface $entity
288
     */
289
    protected function saveUpdate (EntityInterface $entity): void {
290
        $this->cache(__FUNCTION__, function() {
291
            $slots = SQL::slotsEqual(array_keys($this->columns));
292
            unset($slots['id']);
293
            $slots = implode(', ', $slots);
294
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
295
        })->execute($this->getValues($entity));
296
    }
297
298
    /**
299
     * Sets the fetcher if the default columns are used.
300
     *
301
     * @param array $columns Defaults to all columns.
302
     * @return Select
303
     */
304
    public function select (array $columns = []) {
305
        $select = parent::select($columns);
306
        if (empty($columns)) {
307
            $select->setFetcher(function(Statement $statement) {
308
                yield from $this->getEach($statement);
309
            });
310
        }
311
        return $select;
312
    }
313
314
    /**
315
     * @param EntityInterface $proto
316
     * @return $this
317
     */
318
    public function setProto (EntityInterface $proto) {
319
        $this->proto = $proto;
320
        return $this;
321
    }
322
323
    /**
324
     * @param EntityInterface $entity
325
     * @param array $values
326
     */
327
    protected function setValues (EntityInterface $entity, array $values): void {
328
        foreach ($values as $name => $value) {
329
            settype($value, $this->types[$name]);
330
            $this->properties[$name]->setValue($entity, $value);
331
        }
332
    }
333
}