1 | <?php |
||
2 | |||
3 | namespace Simply\Database; |
||
4 | |||
5 | use Simply\Database\Exception\InvalidRelationshipException; |
||
6 | |||
7 | /** |
||
8 | * Represents data loaded from a database. |
||
9 | * @author Riikka Kalliomäki <[email protected]> |
||
10 | * @copyright Copyright (c) 2018 Riikka Kalliomäki |
||
11 | * @license http://opensource.org/licenses/mit-license.php MIT License |
||
12 | */ |
||
13 | class Record implements \ArrayAccess |
||
14 | { |
||
15 | /** A record state when new record is being inserted to database */ |
||
16 | public const STATE_INSERT = 1; |
||
17 | |||
18 | /** A record state when existing record is being updated in the database */ |
||
19 | public const STATE_UPDATE = 2; |
||
20 | |||
21 | /** A record state when the record no longer exists in the database */ |
||
22 | public const STATE_DELETE = 3; |
||
23 | |||
24 | /** @var Schema The schema for the record data */ |
||
25 | private $schema; |
||
26 | |||
27 | /** @var array The primary key for the record at the time of retrieving */ |
||
28 | private $primaryKey; |
||
29 | |||
30 | /** @var array Values for the record fields */ |
||
31 | private $values; |
||
32 | |||
33 | /** @var bool[] Associative list of fields for the record that have been modified */ |
||
34 | private $changed; |
||
35 | |||
36 | /** @var int The current state of the record */ |
||
37 | private $state; |
||
38 | |||
39 | /** @var Record[][] Lists of referenced records for each loaded relationship */ |
||
40 | private $referencedRecords; |
||
41 | |||
42 | /** @var Model|null The model associated with the record */ |
||
43 | private $model; |
||
44 | |||
45 | /** |
||
46 | * Record constructor. |
||
47 | * @param Schema $schema The schema for the record data |
||
48 | * @param Model|null $model The model associated with the record or null if it has not been initialized |
||
49 | */ |
||
50 | 47 | public function __construct(Schema $schema, Model $model = null) |
|
51 | { |
||
52 | 47 | $this->primaryKey = []; |
|
53 | 47 | $this->schema = $schema; |
|
54 | 47 | $this->values = array_fill_keys($schema->getFields(), null); |
|
55 | 47 | $this->state = self::STATE_INSERT; |
|
56 | 47 | $this->changed = []; |
|
57 | 47 | $this->referencedRecords = []; |
|
58 | 47 | $this->model = $model; |
|
59 | 47 | } |
|
60 | |||
61 | /** |
||
62 | * Returns the primary key for the record as it was at the time the record was loaded. |
||
63 | * @return array Associative array of primary key fields and their values |
||
64 | */ |
||
65 | 15 | public function getPrimaryKey(): array |
|
66 | { |
||
67 | 15 | if (empty($this->primaryKey)) { |
|
68 | 1 | throw new \RuntimeException('Cannot refer to the record via primary key, if it is not defined'); |
|
69 | } |
||
70 | |||
71 | 14 | return $this->primaryKey; |
|
72 | } |
||
73 | |||
74 | /** |
||
75 | * Tells if the record is empty, i.e. none of the fields have any values. |
||
76 | * @return bool True if all the fields values are null, false otherwise |
||
77 | */ |
||
78 | 3 | public function isEmpty(): bool |
|
79 | { |
||
80 | 3 | foreach ($this->values as $value) { |
|
81 | 3 | if ($value !== null) { |
|
82 | 3 | return false; |
|
83 | } |
||
84 | } |
||
85 | |||
86 | 1 | return true; |
|
87 | } |
||
88 | |||
89 | /** |
||
90 | * Tells if the record is new and not yet inserted into the database. |
||
91 | * @return bool True if the record has not been inserted into database, false otherwise |
||
92 | */ |
||
93 | 15 | public function isNew(): bool |
|
94 | { |
||
95 | 15 | return $this->state === self::STATE_INSERT; |
|
96 | } |
||
97 | |||
98 | /** |
||
99 | * Tells if the record has been deleted from the database. |
||
100 | * @return bool True if the record no longer exists in the database, false otherwise |
||
101 | */ |
||
102 | 15 | public function isDeleted(): bool |
|
103 | { |
||
104 | 15 | return $this->state === self::STATE_DELETE; |
|
105 | } |
||
106 | |||
107 | /** |
||
108 | * Updates the state of the record after the appropriate database operation. |
||
109 | * @param int $state The appropriate state depending on the performed database operation |
||
110 | */ |
||
111 | 19 | public function updateState(int $state): void |
|
112 | { |
||
113 | 19 | $this->state = $state === self::STATE_DELETE ? self::STATE_DELETE : self::STATE_UPDATE; |
|
114 | 19 | $this->changed = []; |
|
115 | |||
116 | 19 | $this->updatePrimaryKey(); |
|
117 | 19 | } |
|
118 | |||
119 | /** |
||
120 | * Updates the stored primary key based on the field values. |
||
121 | */ |
||
122 | 23 | private function updatePrimaryKey(): void |
|
123 | { |
||
124 | 23 | $this->primaryKey = []; |
|
125 | |||
126 | 23 | foreach ($this->schema->getPrimaryKey() as $key) { |
|
127 | 23 | $this->primaryKey[$key] = $this->values[$key]; |
|
128 | } |
||
129 | 23 | } |
|
130 | |||
131 | /** |
||
132 | * Returns the schema for the record data. |
||
133 | * @return Schema The schema for the record data |
||
134 | */ |
||
135 | 39 | public function getSchema(): Schema |
|
136 | { |
||
137 | 39 | return $this->schema; |
|
138 | } |
||
139 | |||
140 | /** |
||
141 | * Returns the model associated with the record and initializes it if has not been initialized yet. |
||
142 | * @return Model The model associated with the record data |
||
143 | */ |
||
144 | 20 | public function getModel(): Model |
|
145 | { |
||
146 | 20 | if ($this->model === null) { |
|
147 | 20 | $this->model = $this->schema->createModel($this); |
|
148 | } |
||
149 | |||
150 | 20 | return $this->model; |
|
151 | } |
||
152 | |||
153 | /** |
||
154 | * Tells if the referenced records for the given relationship has been loaded. |
||
155 | * @param string $name Name of the relationship |
||
156 | * @return bool True if the referenced records have been loaded, false if not |
||
157 | */ |
||
158 | 5 | public function hasReferencedRecords(string $name): bool |
|
159 | { |
||
160 | 5 | $name = $this->getSchema()->getRelationship($name)->getName(); |
|
161 | |||
162 | 5 | return isset($this->referencedRecords[$name]); |
|
163 | } |
||
164 | |||
165 | /** |
||
166 | * Loads the referenced records for the given relationship. |
||
167 | * @param string $name Name of the relationship |
||
168 | * @param Record[] $records List of records referenced by this record |
||
169 | */ |
||
170 | 9 | public function setReferencedRecords(string $name, array $records): void |
|
171 | { |
||
172 | 9 | $name = $this->getSchema()->getRelationship($name)->getName(); |
|
173 | |||
174 | (function (self ... $records) use ($name): void { |
||
175 | 9 | $this->referencedRecords[$name] = $records; |
|
176 | 9 | })(... $records); |
|
177 | 9 | } |
|
178 | |||
179 | /** |
||
180 | * Returns the list of referenced records for the given relationship. |
||
181 | * @param string $name Name of the relationship |
||
182 | * @return Record[] List of records referenced by this record |
||
183 | */ |
||
184 | 9 | public function getReferencedRecords(string $name): array |
|
185 | { |
||
186 | 9 | $name = $this->getSchema()->getRelationship($name)->getName(); |
|
187 | |||
188 | 9 | if (!isset($this->referencedRecords[$name])) { |
|
189 | 1 | throw new \RuntimeException('The referenced records have not been provided'); |
|
190 | } |
||
191 | |||
192 | 8 | return $this->referencedRecords[$name]; |
|
193 | } |
||
194 | |||
195 | /** |
||
196 | * Sets the referenced fields in this record to reference the record of the given model. |
||
197 | * @param string $name Name of the relationship |
||
198 | * @param Model $model The model that this record should be reference |
||
199 | */ |
||
200 | 7 | public function associate(string $name, Model $model): void |
|
201 | { |
||
202 | 7 | $relationship = $this->getSchema()->getRelationship($name); |
|
203 | |||
204 | 7 | if (!$relationship->isUniqueRelationship()) { |
|
205 | 1 | throw new InvalidRelationshipException('A single model can only be associated to an unique relationships'); |
|
206 | } |
||
207 | |||
208 | 6 | $keys = $relationship->getFields(); |
|
209 | 6 | $fields = $relationship->getReferencedFields(); |
|
210 | 6 | $record = $model->getDatabaseRecord(); |
|
211 | |||
212 | 6 | if ($record->getSchema() !== $relationship->getReferencedSchema()) { |
|
213 | 1 | throw new \InvalidArgumentException('The associated record belongs to incorrect schema'); |
|
214 | } |
||
215 | |||
216 | 5 | while ($keys) { |
|
0 ignored issues
–
show
|
|||
217 | 5 | $value = $record[array_pop($fields)]; |
|
218 | |||
219 | 5 | if ($value === null) { |
|
220 | 1 | throw new \RuntimeException('Cannot associate with models with nulls in referenced fields'); |
|
221 | } |
||
222 | |||
223 | 4 | $this[array_pop($keys)] = $value; |
|
224 | } |
||
225 | |||
226 | 4 | $this->referencedRecords[$relationship->getName()] = [$record]; |
|
227 | 4 | $reverse = $relationship->getReverseRelationship(); |
|
228 | |||
229 | 4 | if ($reverse->isUniqueRelationship()) { |
|
230 | 3 | $record->referencedRecords[$reverse->getName()] = [$this]; |
|
231 | 4 | } elseif ($record->hasReferencedRecords($reverse->getName())) { |
|
232 | 1 | $record->referencedRecords[$reverse->getName()][] = $this; |
|
233 | } |
||
234 | 4 | } |
|
235 | |||
236 | /** |
||
237 | * Sets the referencing fields in the record of the given model to reference this record. |
||
238 | * @param string $name Name of the relationship |
||
239 | * @param Model $model The model that should reference this record |
||
240 | */ |
||
241 | 4 | public function addAssociation(string $name, Model $model): void |
|
242 | { |
||
243 | 4 | $relationship = $this->getSchema()->getRelationship($name); |
|
244 | |||
245 | 4 | if ($relationship->isUniqueRelationship()) { |
|
246 | 1 | throw new InvalidRelationshipException('Cannot add a new model to an unique relationship'); |
|
247 | } |
||
248 | |||
249 | 3 | $model->getDatabaseRecord()->associate($relationship->getReverseRelationship()->getName(), $this->getModel()); |
|
250 | 3 | } |
|
251 | |||
252 | /** |
||
253 | * Returns the model of the referenced record in a unique relationship. |
||
254 | * @param string $name Name of the relationship |
||
255 | * @return Model|null The referenced model, or null if no model is referenced by this record |
||
256 | */ |
||
257 | 5 | public function getRelatedModel(string $name): ?Model |
|
258 | { |
||
259 | 5 | $relationship = $this->getSchema()->getRelationship($name); |
|
260 | |||
261 | 5 | if (!$relationship->isUniqueRelationship()) { |
|
262 | 1 | throw new InvalidRelationshipException('A single model can only be fetched for an unique relationship'); |
|
263 | } |
||
264 | |||
265 | 4 | $records = $this->getReferencedRecords($name); |
|
266 | |||
267 | 4 | if (empty($records)) { |
|
268 | 2 | return null; |
|
269 | } |
||
270 | |||
271 | 3 | return $this->getReferencedRecords($name)[0]->getModel(); |
|
272 | } |
||
273 | |||
274 | /** |
||
275 | * Returns list of models referenced by this record via the given relationship. |
||
276 | * @param string $name Name of the relationship |
||
277 | * @return array List of models referenced by this record |
||
278 | */ |
||
279 | 2 | public function getRelatedModels(string $name): array |
|
280 | { |
||
281 | 2 | $relationship = $this->getSchema()->getRelationship($name); |
|
282 | |||
283 | 2 | if ($relationship->isUniqueRelationship()) { |
|
284 | 1 | throw new InvalidRelationshipException('Cannot fetch multiple models for an unique relationship'); |
|
285 | } |
||
286 | |||
287 | 1 | $models = []; |
|
288 | |||
289 | 1 | foreach ($this->getReferencedRecords($name) as $record) { |
|
290 | 1 | $models[] = $record->getModel(); |
|
291 | } |
||
292 | |||
293 | 1 | return $models; |
|
294 | } |
||
295 | |||
296 | /** |
||
297 | * Gets list of models that are referenced by records that this record references. |
||
298 | * @param string $proxy Name of the relationship in this record |
||
299 | * @param string $name Name of the relationship in the proxy record |
||
300 | * @return array List of models referenced by the records referenced by this record |
||
301 | */ |
||
302 | 5 | public function getRelatedModelsByProxy(string $proxy, string $name): array |
|
303 | { |
||
304 | 5 | $proxyRelationship = $this->getSchema()->getRelationship($proxy); |
|
305 | 5 | $relationship = $proxyRelationship->getReferencedSchema()->getRelationship($name); |
|
306 | |||
307 | 5 | if ($proxyRelationship->isUniqueRelationship()) { |
|
308 | 1 | throw new InvalidRelationshipException('Cannot fetch models via an unique proxy relationship'); |
|
309 | } |
||
310 | |||
311 | 4 | if (!$relationship->isUniqueRelationship()) { |
|
312 | 1 | throw new InvalidRelationshipException('Cannot fetch models via proxy without a unique relationship'); |
|
313 | } |
||
314 | |||
315 | 3 | $models = []; |
|
316 | |||
317 | 3 | foreach ($this->getReferencedRecords($proxy) as $record) { |
|
318 | 3 | $records = $record->getReferencedRecords($name); |
|
319 | |||
320 | 3 | if (empty($records)) { |
|
321 | 2 | continue; |
|
322 | } |
||
323 | |||
324 | 2 | $models[] = $records[0]->getModel(); |
|
325 | } |
||
326 | |||
327 | 3 | return $models; |
|
328 | } |
||
329 | |||
330 | /** |
||
331 | * Returns list of records recursively referenced by this record or any referenced record. |
||
332 | * @return Record[] List of all referenced records and any record they recursively reference |
||
333 | */ |
||
334 | 5 | public function getAllReferencedRecords(): array |
|
335 | { |
||
336 | /** @var Record[] $records */ |
||
337 | 5 | $records = [spl_object_id($this) => $this]; |
|
338 | |||
339 | do { |
||
340 | 5 | foreach (current($records)->referencedRecords as $recordList) { |
|
341 | 1 | foreach ($recordList as $record) { |
|
342 | 1 | $id = spl_object_id($record); |
|
343 | |||
344 | 1 | if (!isset($records[$id])) { |
|
345 | 1 | $records[$id] = $record; |
|
346 | } |
||
347 | } |
||
348 | } |
||
349 | 5 | } while (next($records) !== false); |
|
350 | |||
351 | 5 | return array_values($records); |
|
352 | } |
||
353 | |||
354 | /** |
||
355 | * Sets the values for the fields in the record loaded from the database. |
||
356 | * @param array $row Value for the fields in this record |
||
357 | */ |
||
358 | 18 | public function setDatabaseValues(array $row): void |
|
359 | { |
||
360 | 18 | if (array_keys($row) !== array_keys($this->values)) { |
|
361 | 2 | if (array_diff_key($row, $this->values) !== [] || \count($row) !== \count($this->values)) { |
|
362 | 1 | throw new \InvalidArgumentException('Invalid set of record database values provided'); |
|
363 | } |
||
364 | |||
365 | 1 | $row = array_replace($this->values, $row); |
|
366 | } |
||
367 | |||
368 | 17 | $this->values = $row; |
|
369 | 17 | $this->state = self::STATE_UPDATE; |
|
370 | 17 | $this->changed = []; |
|
371 | 17 | $this->updatePrimaryKey(); |
|
372 | 17 | } |
|
373 | |||
374 | /** |
||
375 | * Returns the values for the fields in this record for storing in database. |
||
376 | * @return array Associative list of fields and their values |
||
377 | */ |
||
378 | 16 | public function getDatabaseValues(): array |
|
379 | { |
||
380 | 16 | return $this->values; |
|
381 | } |
||
382 | |||
383 | /** |
||
384 | * Returns list of all fields that have been modified since the records state was last updated. |
||
385 | * @return string[] List of fields updated since last time the state was updated |
||
386 | */ |
||
387 | 16 | public function getChangedFields(): array |
|
388 | { |
||
389 | 16 | return array_keys($this->changed); |
|
390 | } |
||
391 | |||
392 | /** |
||
393 | * Tells if the value in the given field is other than null. |
||
394 | * @param string $offset Name of the field |
||
395 | * @return bool True if the value is something else than null, false otherwise |
||
396 | */ |
||
397 | 1 | public function offsetExists($offset) |
|
398 | { |
||
399 | 1 | return $this->offsetGet($offset) !== null; |
|
400 | } |
||
401 | |||
402 | /** |
||
403 | * Returns the value for the given field. |
||
404 | * @param string $offset Name of the field |
||
405 | * @return mixed Value for the given field |
||
406 | */ |
||
407 | 18 | public function offsetGet($offset) |
|
408 | { |
||
409 | 18 | $offset = (string) $offset; |
|
410 | |||
411 | 18 | if (!array_key_exists($offset, $this->values)) { |
|
412 | 1 | throw new \InvalidArgumentException("Invalid record field '$offset'"); |
|
413 | } |
||
414 | |||
415 | 17 | return $this->values[$offset]; |
|
416 | } |
||
417 | |||
418 | /** |
||
419 | * Sets the value for the given field. |
||
420 | * @param string $offset Name of the field |
||
421 | * @param mixed $value Value for the given field |
||
422 | */ |
||
423 | 27 | public function offsetSet($offset, $value) |
|
424 | { |
||
425 | 27 | $offset = (string) $offset; |
|
426 | |||
427 | 27 | if (!array_key_exists($offset, $this->values)) { |
|
428 | 1 | throw new \InvalidArgumentException("Invalid record field '$offset'"); |
|
429 | } |
||
430 | |||
431 | 26 | $this->values[$offset] = $value; |
|
432 | 26 | $this->changed[$offset] = true; |
|
433 | 26 | } |
|
434 | |||
435 | /** |
||
436 | * Sets the value of the given field to null and marks it unchanged, if the record has not yet been inserted. |
||
437 | * @param string $offset The name of the field |
||
438 | */ |
||
439 | 1 | public function offsetUnset($offset) |
|
440 | { |
||
441 | 1 | $this->offsetSet($offset, null); |
|
442 | |||
443 | 1 | if ($this->state === self::STATE_INSERT) { |
|
444 | 1 | unset($this->changed[$offset]); |
|
445 | } |
||
446 | 1 | } |
|
447 | } |
||
448 |
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.