Passed
Push — master ( ceff3e...1dcf9b )
by y
02:14
created

Record::asTable()   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 0
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 basic table access so complex queries will fetch as arrays.
128
     *
129
     * @return Table
130
     */
131
    public function asTable () {
132
        return $this->db->getTable($this->name);
133
    }
134
135
    /**
136
     * Returns a {@link Select}.
137
     *
138
     * @see DB::match()
139
     *
140
     * @param array $match `[property => mixed]`
141
     * @param array[] $eavMatch `[eav property => attribute => mixed]`
142
     * @return Select
143
     */
144
    public function find (array $match, array $eavMatch = []) {
145
        $select = $this->select();
146
        foreach ($match as $a => $b) {
147
            $select->where($this->db->match($this[$a] ?? $a, $b));
148
        }
149
        foreach ($eavMatch as $property => $attributes) {
150
            $inner = $this->getEav($property)->find($attributes);
151
            $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

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