Passed
Push — master ( 615897...95e8b1 )
by y
02:20
created

Record::fetchAll()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 2
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 ReflectionNamedType;
9
use ReflectionProperty;
10
11
/**
12
 * Represents an "active record" table, derived from an annotated class implementing {@link EntityInterface}.
13
 *
14
 * Class Annotations:
15
 *
16
 * - `@record TABLE`
17
 *
18
 * Property Annotations:
19
 *
20
 * - `@col` or `@column`
21
 * - `@eav <TABLE>`
22
 *
23
 * Property value types are preserved as long as they are scalar or annotated with `@var`.
24
 * Types may be nullable.
25
 *
26
 * > Annotating the types `String` (capital "S") or `STRING` (all caps) results in `TEXT` and `BLOB`
27
 *
28
 * @method static static factory(DB $db, EntityInterface $proto, string $table, array $columns, array $eav = [])
29
 *
30
 * @TODO Allow constraints in the `column` tag, supporting single and multi-column.
31
 */
32
class Record extends Table {
33
34
    protected const RX_RECORD = '/\*\h*@record\h+(?<table>\w+)/i';
35
    protected const RX_IS_COLUMN = '/\*\h*@col(umn)?\b/i';
36
    protected const RX_VAR = '/\*\h*@var\h+(?<type>\S+)/i'; // includes pipes
37
    protected const RX_NULL = '/\b\|?null\|?\b/i'; // leading or trailing only
38
    protected const RX_IS_SCALAR = '/^bool(ean)?|int(eger)?|float|double|string$/i';
39
    protected const RX_EAV = '/\*\h*@eav\h+(?<table>\w+)/i';
40
    protected const RX_EAV_VAR = '/\*\h*@var\h+(?<type>\w+)\[\]/i'; // typed array
41
42
    /**
43
     * `[property => EAV]`
44
     *
45
     * @var EAV[]
46
     */
47
    protected $eav = [];
48
49
    /**
50
     * `[ property => is nullable ]`
51
     *
52
     * @var bool[]
53
     */
54
    protected $nullable = [];
55
56
    /**
57
     * `[property => ReflectionProperty]`
58
     *
59
     * @var ReflectionProperty[]
60
     */
61
    protected $properties = [];
62
63
    /**
64
     * A boilerplate instance of the class, to clone and populate.
65
     * This defaults to a naively created instance without invoking the constructor.
66
     *
67
     * @var EntityInterface
68
     */
69
    protected $proto;
70
71
    /**
72
     * Scalar property types. Types may be nullable.
73
     *
74
     *  - `bool`
75
     *  - `float` or `double`
76
     *  - `int`
77
     *  - `string`
78
     *
79
     * `[property => type]`
80
     *
81
     * @var string[]
82
     */
83
    protected $types = [];
84
85
    /**
86
     * @param DB $db
87
     * @param string|EntityInterface $class
88
     * @return Record
89
     */
90
    public static function fromClass (DB $db, $class) {
91
        $rClass = new ReflectionClass($class);
92
        assert($rClass->isInstantiable());
93
        $columns = [];
94
        $EAV = [];
95
        foreach ($rClass->getProperties() as $rProp) {
96
            $doc = $rProp->getDocComment();
97
            if (preg_match(static::RX_IS_COLUMN, $doc)) {
98
                $columns[] = $rProp->getName();
99
            }
100
            elseif (preg_match(static::RX_EAV, $doc, $eav)) {
101
                preg_match(static::RX_EAV_VAR, $doc, $var);
102
                $EAV[$rProp->getName()] = EAV::factory($db, $eav['table'], $var['type'] ?? 'string');
103
            }
104
        }
105
        preg_match(static::RX_RECORD, $rClass->getDocComment(), $record);
106
        if (!is_object($class)) {
107
            $class = $rClass->newInstanceWithoutConstructor();
108
        }
109
        return static::factory($db, $class, $record['table'], $columns, $EAV);
110
    }
111
112
    /**
113
     * @param DB $db
114
     * @param EntityInterface $proto
115
     * @param string $table
116
     * @param string[] $columns Property names.
117
     * @param EAV[] $eav Keyed by property name.
118
     */
119
    public function __construct (DB $db, EntityInterface $proto, string $table, array $columns, array $eav = []) {
120
        parent::__construct($db, $table, $columns);
121
        $this->proto = $proto;
122
        $rClass = new ReflectionClass($proto);
123
        $defaults = $rClass->getDefaultProperties();
124
        foreach ($columns as $name) {
125
            $rProp = $rClass->getProperty($name);
126
            $rProp->setAccessible(true);
127
            $this->properties[$name] = $rProp;
128
            // assume nullable string
129
            $type = 'null|string';
130
            // infer the type from reflection
131
            if ($rType = $rProp->getType() and $rType instanceof ReflectionNamedType) {
132
                assert($rType->isBuiltin());
133
                $type = $rType->allowsNull() ? "null|{$rType->getName()}" : $rType->getName();
134
            }
135
            // infer the type from the default value
136
            elseif (isset($defaults[$name])) {
137
                $type = gettype($defaults[$name]);
138
            }
139
            // always go with @var if present
140
            if (preg_match(static::RX_VAR, $rProp->getDocComment(), $var)) {
141
                $type = $var['type'];
142
            }
143
            // extract nullable
144
            $type = preg_replace(static::RX_NULL, '', $type, -1, $nullable);
145
            $this->nullable[$name] = (bool)$nullable;
146
            // fall back to "string" if it's not a native scalar (e.g. "number")
147
            if (!preg_match(static::RX_IS_SCALAR, $type)) {
148
                $type = 'string';
149
            }
150
            $this->types[$name] = $type;
151
        }
152
        $this->nullable['id'] = false;
153
        $this->types['id'] = 'int';
154
        $this->eav = $eav;
155
        foreach (array_keys($eav) as $name) {
156
            $rProp = $rClass->getProperty($name);
157
            $rProp->setAccessible(true);
158
            $this->properties[$name] = $rProp;
159
        }
160
    }
161
162
    /**
163
     * Fetches from a statement into clones of the entity prototype.
164
     *
165
     * @param Statement $statement
166
     * @return EntityInterface[] Keyed by ID
167
     */
168
    public function fetchAll (Statement $statement): array {
169
        return iterator_to_array($this->fetchEach($statement));
170
    }
171
172
    /**
173
     * Fetches in chunks and yields each loaded entity.
174
     * This is preferable over {@link fetchAll()} for iterating large result sets.
175
     *
176
     * @param Statement $statement
177
     * @return Generator|EntityInterface[] Keyed by ID
178
     */
179
    public function fetchEach (Statement $statement) {
180
        do {
181
            $entities = [];
182
            for ($i = 0; $i < 256 and false !== $row = $statement->fetch(); $i++) {
183
                $clone = clone $this->proto;
184
                $this->setValues($clone, $row);
185
                $entities[$row['id']] = $clone;
186
            }
187
            $this->loadEav($entities);
188
            yield from $entities;
189
        } while (!empty($entities));
190
    }
191
192
    /**
193
     * Similar to {@link loadAll()} except this can additionally search by {@link EAV} values.
194
     *
195
     * @see DB::match()
196
     *
197
     * @param array $match `[property => value]`
198
     * @param array[] $eavMatch `[eav property => attribute => value]`
199
     * @return Select|EntityInterface[]
200
     */
201
    public function findAll (array $match, array $eavMatch = []) {
202
        $select = $this->loadAll();
203
        foreach ($match as $a => $b) {
204
            $select->where($this->db->match($this[$a] ?? $a, $b));
205
        }
206
        foreach ($eavMatch as $property => $attributes) {
207
            $inner = $this->eav[$property]->findAll($attributes);
208
            $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

208
            $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...
209
        }
210
        return $select;
211
    }
212
213
    /**
214
     * Returns an instance for the first row matching the criteria.
215
     *
216
     * @param array $match `[property => value]`
217
     * @param array $eavMatch `[eav property => attribute => value]`
218
     * @return null|EntityInterface
219
     */
220
    public function findFirst (array $match, array $eavMatch = []) {
221
        return $this->findAll($match, $eavMatch)->limit(1)->getFirst();
222
    }
223
224
    /**
225
     * @return string
226
     */
227
    final public function getClass (): string {
228
        return get_class($this->proto);
229
    }
230
231
    /**
232
     * @return EAV[]
233
     */
234
    public function getEav () {
235
        return $this->eav;
236
    }
237
238
    /**
239
     * Enumerated property names.
240
     *
241
     * @return string[]
242
     */
243
    final public function getProperties (): array {
244
        return array_keys($this->properties);
245
    }
246
247
    /**
248
     * @return EntityInterface
249
     */
250
    public function getProto () {
251
        return $this->proto;
252
    }
253
254
    /**
255
     * Returns the native/annotated property types.
256
     *
257
     * This doesn't include whether the property is nullable. Use {@link isNullable()} for that.
258
     *
259
     * @return string[]
260
     */
261
    final public function getTypes (): array {
262
        return $this->types;
263
    }
264
265
    /**
266
     * @param EntityInterface $entity
267
     * @return array
268
     */
269
    protected function getValues (EntityInterface $entity): array {
270
        $values = [];
271
        foreach (array_keys($this->columns) as $name) {
272
            $values[$name] = $this->properties[$name]->getValue($entity);
273
        }
274
        return $values;
275
    }
276
277
    /**
278
     * @param string $property
279
     * @return bool
280
     */
281
    final public function isNullable (string $property): bool {
282
        return $this->nullable[$property];
283
    }
284
285
    /**
286
     * Loads all data for a given ID into a clone of the prototype.
287
     *
288
     * @param int $id
289
     * @return null|EntityInterface
290
     */
291
    public function load (int $id) {
292
        $statement = $this->cache(__FUNCTION__, function() {
293
            return $this->select()->where('id = ?')->prepare();
294
        });
295
        $values = $statement([$id])->fetch();
296
        $statement->closeCursor();
297
        if ($values) {
298
            $entity = clone $this->proto;
299
            $this->setValues($entity, $values);
300
            $this->loadEav([$id => $entity]);
301
            return $entity;
302
        }
303
        return null;
304
    }
305
306
    /**
307
     * Returns a {@link Select} that fetches instances.
308
     *
309
     * @return Select|EntityInterface[]
310
     */
311
    public function loadAll () {
312
        return $this->select()->setFetcher(function(Statement $statement) {
313
            yield from $this->fetchEach($statement);
314
        });
315
    }
316
317
    /**
318
     * Loads and sets all EAV properties for an array of entities keyed by ID.
319
     *
320
     * @param EntityInterface[] $entities
321
     */
322
    protected function loadEav (array $entities): void {
323
        $ids = array_keys($entities);
324
        foreach ($this->eav as $name => $eav) {
325
            foreach ($eav->loadAll($ids) as $id => $values) {
326
                $this->properties[$name]->setValue($entities[$id], $values);
327
            }
328
        }
329
    }
330
331
    /**
332
     * Upserts record and EAV data.
333
     *
334
     * @param EntityInterface $entity
335
     * @return int ID
336
     */
337
    public function save (EntityInterface $entity): int {
338
        if (!$entity->getId()) {
339
            $this->saveInsert($entity);
340
        }
341
        else {
342
            $this->saveUpdate($entity);
343
        }
344
        $this->saveEav($entity);
345
        return $entity->getId();
346
    }
347
348
    /**
349
     * @param EntityInterface $entity
350
     */
351
    protected function saveEav (EntityInterface $entity): void {
352
        $id = $entity->getId();
353
        foreach ($this->eav as $name => $eav) {
354
            // may be null to skip
355
            $values = $this->properties[$name]->getValue($entity);
356
            if (isset($values)) {
357
                $eav->save($id, $values);
358
            }
359
        }
360
    }
361
362
    /**
363
     * Inserts a new row and updates the entity's ID.
364
     *
365
     * @param EntityInterface $entity
366
     */
367
    protected function saveInsert (EntityInterface $entity): void {
368
        $statement = $this->cache(__FUNCTION__, function() {
369
            $slots = $this->db->slots(array_keys($this->columns));
370
            unset($slots['id']);
371
            $columns = implode(',', array_keys($slots));
372
            $slots = implode(',', $slots);
373
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
374
        });
375
        $values = $this->getValues($entity);
376
        unset($values['id']);
377
        $this->properties['id']->setValue($entity, $statement($values)->getId());
378
        $statement->closeCursor();
379
    }
380
381
    /**
382
     * Updates the existing row for the entity.
383
     *
384
     * @param EntityInterface $entity
385
     */
386
    protected function saveUpdate (EntityInterface $entity): void {
387
        $statement = $this->cache(__FUNCTION__, function() {
388
            $slots = $this->db->slotsEqual(array_keys($this->columns));
389
            unset($slots['id']);
390
            $slots = implode(', ', $slots);
391
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
392
        });
393
        $statement->execute($this->getValues($entity));
394
        $statement->closeCursor();
395
    }
396
397
    /**
398
     * @param EntityInterface $proto
399
     * @return $this
400
     */
401
    public function setProto (EntityInterface $proto) {
402
        $this->proto = $proto;
403
        return $this;
404
    }
405
406
    /**
407
     * Converts a value from storage into the native/annotated type.
408
     *
409
     * @param string $property
410
     * @param mixed $value
411
     * @return mixed
412
     */
413
    protected function setType (string $property, $value) {
414
        if (isset($value)) {
415
            // doesn't care about the type's letter case
416
            settype($value, $this->types[$property]);
417
        }
418
        return $value;
419
    }
420
421
    /**
422
     * @param EntityInterface $entity
423
     * @param array $values
424
     */
425
    protected function setValues (EntityInterface $entity, array $values): void {
426
        foreach ($values as $name => $value) {
427
            if (isset($this->properties[$name])) {
428
                $this->properties[$name]->setValue($entity, $this->setType($name, $value));
429
            }
430
            else {
431
                // attempt to set unknown fields directly on the instance.
432
                $entity->{$name} = $value;
433
            }
434
        }
435
    }
436
}