Passed
Push — master ( 285c17...9cf790 )
by y
02:17 queued 13s
created

Record::isUnique()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
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
     * Loads all data for a given ID (clones the prototype), or an existing instance.
164
     *
165
     * @param int|EntityInterface $id The given instance may be a subclass of the prototype.
166
     * @return null|EntityInterface
167
     */
168
    public function load($id)
169
    {
170
        $statement = $this->cache(__FUNCTION__, function () {
171
            return $this->select()->where('id = ?')->prepare();
172
        });
173
        if ($id instanceof EntityInterface) {
174
            assert(is_a($id, get_class($this->proto)));
175
            $entity = $id;
176
            $id = $entity->getId();
177
        } else {
178
            $entity = clone $this->proto;
179
        }
180
        $values = $statement([$id])->fetch();
181
        $statement->closeCursor();
182
        if ($values) {
183
            $this->serializer->import($entity, $values);
184
            $this->loadEav([$id => $entity]);
185
            return $entity;
186
        }
187
        return null;
188
    }
189
190
    /**
191
     * Returns a {@link Select} that fetches instances.
192
     *
193
     * @return Select|EntityInterface[]
194
     */
195
    public function loadAll()
196
    {
197
        return $this->select()->setFetcher(function (Statement $statement) {
198
            yield from $this->fetchEach($statement);
199
        });
200
    }
201
202
    /**
203
     * Loads and sets all EAV properties for an array of entities keyed by ID.
204
     *
205
     * @param EntityInterface[] $entities Keyed by ID
206
     */
207
    protected function loadEav(array $entities): void
208
    {
209
        $ids = array_keys($entities);
210
        foreach ($this->eav as $attr => $eav) {
211
            foreach ($eav->loadAll($ids) as $id => $values) {
212
                $this->serializer->setValue($entities[$id], $attr, $values);
213
            }
214
        }
215
    }
216
217
    /**
218
     * Upserts record and EAV data.
219
     *
220
     * @param EntityInterface $entity
221
     * @return int ID
222
     */
223
    public function save(EntityInterface $entity): int
224
    {
225
        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...
226
            $this->saveInsert($entity);
227
        } else {
228
            $this->saveUpdate($entity);
229
        }
230
        $this->saveEav($entity);
231
        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...
232
    }
233
234
    /**
235
     * @param EntityInterface $entity
236
     */
237
    protected function saveEav(EntityInterface $entity): void
238
    {
239
        $id = $entity->getId();
240
        foreach ($this->eav as $attr => $eav) {
241
            $values = $this->serializer->getValue($entity, $attr);
242
            // skip if null
243
            if (isset($values)) {
244
                $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

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