hfw /
db
| 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
|
|||||
| 234 | $this->saveInsert($entity); |
||||
| 235 | } else { |
||||
| 236 | $this->saveUpdate($entity); |
||||
| 237 | } |
||||
| 238 | $this->saveEav($entity); |
||||
| 239 | return $entity->getId(); |
||||
|
0 ignored issues
–
show
|
|||||
| 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
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
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 |
In PHP, under loose comparison (like
==, or!=, orswitchconditions), values of different types might be equal.For
integervalues, zero is a special case, in particular the following results might be unexpected: