Passed
Push — master ( 9cf790...f45573 )
by y
06:06
created

Record::getSerializer()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 1
b 0
f 0
1
<?php
2
3
namespace Helix\DB;
4
5
use Generator;
6
use Helix\DB;
7
use Helix\DB\Fluent\Predicate;
8
use Helix\DB\Record\Serializer;
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
 * - `@unique` or `@unique <SHARED_IDENTIFIER>` for a single or multi-column unique-key.
21
 *  - The shared identifier must be alphabetical, allowing underscores.
22
 *  - The identifier can be arbitrary, but it's necessary in order to associate component properties.
23
 *  - The column/s may be nullable; MySQL and SQLite don't enforce uniqueness for NULL.
24
 * - `@eav <TABLE>`
25
 *
26
 * Property types are preserved.
27
 * Properties which are objects can be dehydrated/rehydrated if they're strictly typed.
28
 * Strict typing is preferred, but annotations and finally default values are used as fallbacks.
29
 *
30
 * > Annotating the types `String` (capital "S") or `STRING` (all caps) results in `TEXT` and `BLOB`
31
 *
32
 * @method static static factory(DB $db, string|EntityInterface $class)
33
 */
34
class Record extends Table
35
{
36
37
    /**
38
     * The number of entities to load EAV entries for at a time,
39
     * during {@link Record::fetchEach()} iteration.
40
     */
41
    protected const EAV_BATCH_LOAD = 256;
42
43
    /**
44
     * `[property => EAV]`
45
     *
46
     * @var EAV[]
47
     */
48
    protected $eav = [];
49
50
    /**
51
     * A boilerplate instance of the class, to clone and populate.
52
     *
53
     * @var EntityInterface
54
     */
55
    protected $proto;
56
57
    protected Serializer $serializer;
58
59
    /**
60
     * @param DB $db
61
     * @param string|EntityInterface $class
62
     */
63
    public function __construct(DB $db, $class)
64
    {
65
        $this->serializer = Serializer::factory($db, $class);
66
        $this->proto = is_object($class) ? $class : $this->serializer->newProto();
67
        assert($this->proto instanceof EntityInterface);
68
        $this->eav = $this->serializer->getEav();
69
        parent::__construct($db, $this->serializer->getRecordTable(), $this->serializer->getColumns());
70
    }
71
72
    /**
73
     * Fetches from a statement into clones of the entity prototype.
74
     *
75
     * @param Statement $statement
76
     * @return EntityInterface[] Keyed by ID
77
     */
78
    public function fetchAll(Statement $statement): array
79
    {
80
        return iterator_to_array($this->fetchEach($statement));
81
    }
82
83
    /**
84
     * Fetches in chunks and yields each loaded entity.
85
     * This is preferable over {@link fetchAll()} for iterating large result sets.
86
     *
87
     * @param Statement $statement
88
     * @return Generator|EntityInterface[] Keyed by ID
89
     */
90
    public function fetchEach(Statement $statement)
91
    {
92
        do {
93
            $entities = [];
94
            for ($i = 0; $i < static::EAV_BATCH_LOAD and false !== $row = $statement->fetch(); $i++) {
95
                $clone = clone $this->proto;
96
                $this->serializer->import($clone, $row);
97
                $entities[$row['id']] = $clone;
98
            }
99
            $this->loadEav($entities);
100
            yield from $entities;
101
        } while (!empty($entities));
102
    }
103
104
    /**
105
     * Similar to {@link loadAll()} except this can additionally search by {@link EAV} values.
106
     *
107
     * @see Predicate::match()
108
     *
109
     * @param array $match `[property => value]`
110
     * @param array[] $eavMatch `[eav property => attribute => value]`
111
     * @return Select|EntityInterface[]
112
     */
113
    public function findAll(array $match, array $eavMatch = [])
114
    {
115
        $select = $this->loadAll();
116
        foreach ($match as $a => $b) {
117
            $select->where(Predicate::match($this->db, $this[$a] ?? $a, $b));
118
        }
119
        foreach ($eavMatch as $property => $attributes) {
120
            $inner = $this->eav[$property]->findAll($attributes);
121
            $select->join($inner, $inner['entity']->isEqual($this['id']));
122
        }
123
        return $select;
124
    }
125
126
    /**
127
     * Returns an instance for the first row matching the criteria.
128
     *
129
     * @param array $match `[property => value]`
130
     * @param array $eavMatch `[eav property => attribute => value]`
131
     * @return null|EntityInterface
132
     */
133
    public function findFirst(array $match, array $eavMatch = [])
134
    {
135
        return $this->findAll($match, $eavMatch)->limit(1)->getFirst();
136
    }
137
138
    /**
139
     * @return string
140
     */
141
    final public function getClass(): string
142
    {
143
        return get_class($this->proto);
144
    }
145
146
    /**
147
     * @return EAV[]
148
     */
149
    public function getEav()
150
    {
151
        return $this->eav;
152
    }
153
154
    /**
155
     * @return EntityInterface
156
     */
157
    public function getProto()
158
    {
159
        return $this->proto;
160
    }
161
162
    /**
163
     * @return Serializer
164
     */
165
    public function getSerializer(): Serializer
166
    {
167
        return $this->serializer;
168
    }
169
170
    /**
171
     * Loads all data for a given ID (clones the prototype), or an existing instance.
172
     *
173
     * @param int|EntityInterface $id The given instance may be a subclass of the prototype.
174
     * @return null|EntityInterface
175
     */
176
    public function load($id)
177
    {
178
        $statement = $this->cache(__FUNCTION__, function () {
179
            return $this->select()->where('id = ?')->prepare();
180
        });
181
        if ($id instanceof EntityInterface) {
182
            assert(is_a($id, get_class($this->proto)));
183
            $entity = $id;
184
            $id = $entity->getId();
185
        } else {
186
            $entity = clone $this->proto;
187
        }
188
        $values = $statement([$id])->fetch();
189
        $statement->closeCursor();
190
        if ($values) {
191
            $this->serializer->import($entity, $values);
192
            $this->loadEav([$id => $entity]);
193
            return $entity;
194
        }
195
        return null;
196
    }
197
198
    /**
199
     * Returns a {@link Select} that fetches instances.
200
     *
201
     * @return Select|EntityInterface[]
202
     */
203
    public function loadAll()
204
    {
205
        return $this->select()->setFetcher(function (Statement $statement) {
206
            yield from $this->fetchEach($statement);
207
        });
208
    }
209
210
    /**
211
     * Loads and sets all EAV properties for an array of entities keyed by ID.
212
     *
213
     * @param EntityInterface[] $entities Keyed by ID
214
     */
215
    protected function loadEav(array $entities): void
216
    {
217
        $ids = array_keys($entities);
218
        foreach ($this->eav as $attr => $eav) {
219
            foreach ($eav->loadAll($ids) as $id => $values) {
220
                $this->serializer->setValue($entities[$id], $attr, $values);
221
            }
222
        }
223
    }
224
225
    /**
226
     * Upserts record and EAV data.
227
     *
228
     * @param EntityInterface $entity
229
     * @return int ID
230
     */
231
    public function save(EntityInterface $entity): int
232
    {
233
        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...
234
            $this->saveInsert($entity);
235
        } else {
236
            $this->saveUpdate($entity);
237
        }
238
        $this->saveEav($entity);
239
        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...
240
    }
241
242
    /**
243
     * @param EntityInterface $entity
244
     */
245
    protected function saveEav(EntityInterface $entity): void
246
    {
247
        $id = $entity->getId();
248
        foreach ($this->eav as $attr => $eav) {
249
            $values = $this->serializer->getValue($entity, $attr);
250
            // skip if null
251
            if (isset($values)) {
252
                $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

252
                $eav->save(/** @scrutinizer ignore-type */ $id, $values);
Loading history...
253
            }
254
        }
255
    }
256
257
    /**
258
     * Inserts a new row and updates the entity's ID.
259
     *
260
     * @param EntityInterface $entity
261
     */
262
    protected function saveInsert(EntityInterface $entity): void
263
    {
264
        $statement = $this->cache(__FUNCTION__, function () {
265
            $slots = $this->db->slots(array_keys($this->columns));
266
            unset($slots['id']);
267
            $columns = implode(',', array_keys($slots));
268
            $slots = implode(',', $slots);
269
            return $this->db->prepare("INSERT INTO {$this} ({$columns}) VALUES ({$slots})");
270
        });
271
        $values = $this->serializer->export($entity);
272
        unset($values['id']);
273
        $this->serializer->setValue($entity, 'id', $statement($values)->getId());
274
        $statement->closeCursor();
275
    }
276
277
    /**
278
     * Updates the existing row for the entity.
279
     *
280
     * @param EntityInterface $entity
281
     */
282
    protected function saveUpdate(EntityInterface $entity): void
283
    {
284
        $statement = $this->cache(__FUNCTION__, function () {
285
            $slots = $this->db->slots(array_keys($this->columns));
286
            foreach ($slots as $column => $slot) {
287
                $slots[$column] = "{$column} = {$slot}";
288
            }
289
            unset($slots['id']);
290
            $slots = implode(', ', $slots);
291
            return $this->db->prepare("UPDATE {$this} SET {$slots} WHERE id = :id");
292
        });
293
        $values = $this->serializer->export($entity);
294
        $statement->execute($values);
295
        $statement->closeCursor();
296
    }
297
298
    /**
299
     * @param EntityInterface $proto
300
     * @return $this
301
     */
302
    public function setProto(EntityInterface $proto)
303
    {
304
        $this->proto = $proto;
305
        return $this;
306
    }
307
308
}
309