Passed
Push — master ( 1dcf9b...c6a312 )
by y
01:25
created

Record::select()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
namespace Helix\DB;
4
5
use Generator;
6
use Helix\DB;
7
use ReflectionClass;
8
use ReflectionProperty;
9
10
/**
11
 * Represents an "active record" table, derived from an annotated class implementing {@link EntityInterface}.
12
 *
13
 * Class Annotations:
14
 *
15
 * - `@record TABLE`
16
 *
17
 * Property Annotations:
18
 *
19
 * - `@col` or `@column`
20
 * - `@eav TABLE`
21
 *
22
 * Property value types are preserved as long as they are annotated with `@var`.
23
 *
24
 * @method static static factory(DB $db, EntityInterface $proto, string $table, array $columns, array $eav = [])
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
     * Scalar property types.
52
     *
53
     *  - bool
54
     *  - float (or double)
55
     *  - int
56
     *  - string
57
     *
58
     * `[property => type]`
59
     *
60
     * @var array
61
     */
62
    protected $types = [];
63
64
    /**
65
     * @param DB $db
66
     * @param string|EntityInterface $class
67
     * @return Record
68
     */
69
    public static function fromClass (DB $db, $class) {
70
        return (function() use ($db, $class) {
71
            $rClass = new ReflectionClass($class);
72
            $columns = [];
73
            /** @var EAV[] $eav */
74
            $eav = [];
75
            foreach ($rClass->getProperties() as $rProp) {
76
                if (preg_match('/@col(umn)?[\s$]/', $rProp->getDocComment())) {
77
                    $columns[] = $rProp->getName();
78
                }
79
                elseif (preg_match('/@eav\s+(?<table>\S+)/', $rProp->getDocComment(), $attr)) {
80
                    $eav[$rProp->getName()] = EAV::factory($db, $attr['table']);
81
                }
82
            }
83
            preg_match('/@record\s+(?<table>\S+)/', $rClass->getDocComment(), $record);
84
            if (!is_object($class)) {
85
                $class = $rClass->newInstanceWithoutConstructor();
86
            }
87
            return static::factory($db, $class, $record['table'], $columns, $eav);
88
        })();
89
    }
90
91
    /**
92
     * @param DB $db
93
     * @param EntityInterface $proto
94
     * @param string $table
95
     * @param string[] $columns Property names.
96
     * @param EAV[] $eav Keyed by property name.
97
     */
98
    public function __construct (DB $db, EntityInterface $proto, string $table, array $columns, array $eav = []) {
99
        parent::__construct($db, $table, $columns);
100
        $this->proto = $proto;
101
        (function() use ($proto, $columns, $eav) {
102
            $rClass = new ReflectionClass($proto);
103
            $defaults = $rClass->getDefaultProperties();
104
            foreach ($columns as $name) {
105
                $rProp = $rClass->getProperty($name);
106
                $rProp->setAccessible(true);
107
                $this->properties[$name] = $rProp;
108
                // infer the type from the default value
109
                $type = gettype($defaults[$name] ?? 'string');
110
                // check for explicit type via annotation
111
                if (preg_match('/@var\s+(?<type>[a-z]+)[\s$]/', $rProp->getDocComment(), $var)) {
112
                    $type = $var['type'];
113
                }
114
                $this->types[$name] = $type;
115
            }
116
            $this->types['id'] = 'int';
117
            $this->eav = $eav;
118
            foreach (array_keys($eav) as $name) {
119
                $rProp = $rClass->getProperty($name);
120
                $rProp->setAccessible(true);
121
                $this->properties[$name] = $rProp;
122
            }
123
        })();
124
    }
125
126
    /**
127
     * Returns a {@link Select} that fetches instances.
128
     *
129
     * @see DB::match()
130
     *
131
     * @param array $match `[property => mixed]`
132
     * @param array[] $eavMatch `[eav property => attribute => mixed]`
133
     * @return Select|EntityInterface[]
134
     */
135
    public function find (array $match, array $eavMatch = []) {
136
        $select = $this->loadAll();
137
        foreach ($match as $a => $b) {
138
            $select->where($this->db->match($this[$a] ?? $a, $b));
139
        }
140
        foreach ($eavMatch as $property => $attributes) {
141
            $inner = $this->getEav($property)->find($attributes);
142
            $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

142
            $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...
143
        }
144
        return $select;
145
    }
146
147
    /**
148
     * Fetches from a statement into clones of the entity prototype.
149
     *
150
     * @param Statement $statement
151
     * @return EntityInterface[] Keyed by ID
152
     */
153
    public function getAll (Statement $statement): array {
154
        return iterator_to_array($this->getEach($statement));
155
    }
156
157
    /**
158
     * Fetches in chunks and yields each loaded entity.
159
     * This is preferable over {@link getAll()} for iterating large result sets.
160
     *
161
     * @param Statement $statement
162
     * @return Generator|EntityInterface[] Keyed by ID
163
     */
164
    public function getEach (Statement $statement) {
165
        do {
166
            $entities = [];
167
            for ($i = 0; $i < 256 and false !== $row = $statement->fetch(); $i++) {
168
                $clone = clone $this->proto;
169
                $this->setValues($clone, $row);
170
                $entities[$row['id']] = $clone;
171
            }
172
            $this->loadEav($entities);
173
            yield from $entities;
174
        } while (!empty($entities));
175
    }
176
177
    /**
178
     * @param string $property
179
     * @return EAV
180
     */
181
    final public function getEav (string $property) {
182
        return $this->eav[$property];
183
    }
184
185
    /**
186
     * @return EntityInterface
187
     */
188
    public function getProto () {
189
        return $this->proto;
190
    }
191
192
    /**
193
     * @param EntityInterface $entity
194
     * @return array
195
     */
196
    protected function getValues (EntityInterface $entity): array {
197
        $values = [];
198
        foreach (array_keys($this->columns) as $name) {
199
            $values[$name] = $this->properties[$name]->getValue($entity);
200
        }
201
        return $values;
202
    }
203
204
    /**
205
     * Loads all data for a given ID into a clone of the prototype.
206
     *
207
     * @param int $id
208
     * @return null|EntityInterface
209
     */
210
    public function load (int $id) {
211
        $statement = $this->cache(__FUNCTION__, function() {
212
            return $this->select(array_keys($this->columns))->where('id = ?')->prepare();
213
        });
214
        $values = $statement([$id])->fetch();
215
        $statement->closeCursor();
216
        if ($values) {
217
            $entity = clone $this->proto;
218
            $this->setValues($entity, $values);
219
            $this->loadEav([$id => $entity]);
220
            return $entity;
221
        }
222
        return null;
223
    }
224
225
    /**
226
     * Returns a {@link Select} that fetches instances.
227
     *
228
     * @return Select|EntityInterface[]
229
     */
230
    public function loadAll () {
231
        return $this->select()->setFetcher(function(Statement $statement) {
232
            yield from $this->getEach($statement);
233
        });
234
    }
235
236
    /**
237
     * Loads and sets all EAV properties for an array of entities keyed by ID.
238
     *
239
     * @param EntityInterface[] $entities
240
     */
241
    protected function loadEav (array $entities): void {
242
        $ids = array_keys($entities);
243
        foreach ($this->eav as $name => $eav) {
244
            foreach ($eav->loadAll($ids) as $id => $values) {
245
                $this->properties[$name]->setValue($entities[$id], $values);
246
            }
247
        }
248
    }
249
250
    /**
251
     * Upserts record and EAV data.
252
     *
253
     * @param EntityInterface $entity
254
     * @return int ID
255
     */
256
    public function save (EntityInterface $entity): int {
257
        if (!$entity->getId()) {
258
            $this->saveInsert($entity);
259
        }
260
        else {
261
            $this->saveUpdate($entity);
262
        }
263
        $this->saveEav($entity);
264
        return $entity->getId();
265
    }
266
267
    /**
268
     * @param EntityInterface $entity
269
     */
270
    protected function saveEav (EntityInterface $entity): void {
271
        $id = $entity->getId();
272
        foreach ($this->eav as $name => $eav) {
273
            // may be null to skip
274
            $values = $this->properties[$name]->getValue($entity);
275
            if (isset($values)) {
276
                $eav->save($id, $values);
277
            }
278
        }
279
    }
280
281
    /**
282
     * Inserts a new row and updates the entity's ID.
283
     *
284
     * @param EntityInterface $entity
285
     */
286
    protected function saveInsert (EntityInterface $entity): void {
287
        $statement = $this->cache(__FUNCTION__, function() {
288
            $slots = SQL::slots(array_keys($this->columns));
289
            unset($slots['id']);
290
            $columns = implode(',', array_keys($slots));
291
            $slots = implode(',', $slots);
292
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
293
        });
294
        $values = $this->getValues($entity);
295
        unset($values['id']);
296
        $this->properties['id']->setValue($entity, $statement($values)->getId());
297
        $statement->closeCursor();
298
    }
299
300
    /**
301
     * Updates the existing row for the entity.
302
     *
303
     * @param EntityInterface $entity
304
     */
305
    protected function saveUpdate (EntityInterface $entity): void {
306
        $statement = $this->cache(__FUNCTION__, function() {
307
            $slots = SQL::slotsEqual(array_keys($this->columns));
308
            unset($slots['id']);
309
            $slots = implode(', ', $slots);
310
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
311
        });
312
        $statement->execute($this->getValues($entity));
313
        $statement->closeCursor();
314
    }
315
316
    /**
317
     * @param EntityInterface $proto
318
     * @return $this
319
     */
320
    public function setProto (EntityInterface $proto) {
321
        $this->proto = $proto;
322
        return $this;
323
    }
324
325
    /**
326
     * @param EntityInterface $entity
327
     * @param array $values
328
     */
329
    protected function setValues (EntityInterface $entity, array $values): void {
330
        foreach ($values as $name => $value) {
331
            if (isset($this->properties[$name])) {
332
                settype($value, $this->types[$name]);
333
                $this->properties[$name]->setValue($entity, $value);
334
            }
335
            else {
336
                $entity->{$name} = $value;
337
            }
338
        }
339
    }
340
}