Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.63% |
162 / 187 |
|
73.91% |
17 / 23 |
CRAP | |
0.00% |
0 / 1 |
AbstractDataModel | |
86.63% |
162 / 187 |
|
73.91% |
17 / 23 |
116.56 | |
0.00% |
0 / 1 |
fetchAll | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
fetch | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createNew | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
filterBy | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAll | |
75.00% |
15 / 20 |
|
0.00% |
0 / 1 |
7.77 | |||
getById | |
41.18% |
7 / 17 |
|
0.00% |
0 / 1 |
30.35 | |||
create | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
copy | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
replace | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
6 | |||
update | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
delete | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
remove | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
count | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
6.73 | |||
describe | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
9.16 | |||
hasRequirements | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
validate | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
filter | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
select | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
10.66 | |||
getTableClass | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
5.39 | |||
getPrimaryId | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getOffsetAndLimit | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
getOrderBy | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
7 | |||
parseFilter | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Pop PHP Framework (http://www.popphp.org/) |
4 | * |
5 | * @link https://github.com/popphp/popphp-framework |
6 | * @author Nick Sagona, III <dev@nolainteractive.com> |
7 | * @copyright Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com) |
8 | * @license http://www.popphp.org/license New BSD License |
9 | */ |
10 | |
11 | /** |
12 | * @namespace |
13 | */ |
14 | namespace Pop\Model; |
15 | |
16 | use Pop\Db\Record; |
17 | use Pop\Db\Record\Collection; |
18 | use Pop\Db\Sql\Parser; |
19 | |
20 | /** |
21 | * Abstract data model class |
22 | * |
23 | * @category Pop |
24 | * @package Pop |
25 | * @author Nick Sagona, III <dev@nolainteractive.com> |
26 | * @copyright Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com) |
27 | * @license http://www.popphp.org/license New BSD License |
28 | * @version 4.2.0 |
29 | */ |
30 | abstract class AbstractDataModel extends AbstractModel implements DataModelInterface |
31 | { |
32 | |
33 | /** |
34 | * Data table class |
35 | * @var ?string |
36 | */ |
37 | protected ?string $table = null; |
38 | |
39 | /** |
40 | * Filters |
41 | * @var array |
42 | */ |
43 | protected array $filters = []; |
44 | |
45 | /** |
46 | * Options |
47 | * @var array |
48 | */ |
49 | protected array $options = []; |
50 | |
51 | /** |
52 | * Requirements |
53 | * @var array |
54 | */ |
55 | protected array $requirements = []; |
56 | |
57 | /** |
58 | * Select columns |
59 | * - Columns to show for general select queries. Can include foreign columns |
60 | * if the $foreignTables property is properly configured |
61 | * @var array |
62 | */ |
63 | protected array $selectColumns = []; |
64 | |
65 | /** |
66 | * Private columns |
67 | * - Columns of sensitive data to hide from general select queries (i.e. passwords, etc.) |
68 | * @var array |
69 | */ |
70 | protected array $privateColumns = []; |
71 | |
72 | /** |
73 | * Foreign tables |
74 | * - List of foreign tables and columns to use in general select queries as JOINS |
75 | * [ |
76 | * 'table' => 'foreign_table', |
77 | * 'columns' => ['foreign_table.id' => 'table.foreign_id'] |
78 | * ] |
79 | * @var array |
80 | */ |
81 | protected array $foreignTables = []; |
82 | |
83 | /** |
84 | * Original select columns |
85 | * - Property to track original select columns |
86 | * @var array |
87 | */ |
88 | private array $origSelectColumns = []; |
89 | |
90 | /** |
91 | * Fetch all |
92 | * |
93 | * @param ?string $sort |
94 | * @param mixed $limit |
95 | * @param mixed $page |
96 | * @param bool $asArray |
97 | * @throws Exception |
98 | * @return array|Collection |
99 | */ |
100 | public static function fetchAll(?string $sort = null, mixed $limit = null, mixed $page = null, bool $asArray = true): array|Collection |
101 | { |
102 | return (new static())->getAll($sort, $limit, $page, $asArray); |
103 | } |
104 | |
105 | /** |
106 | * Fetch by ID |
107 | * |
108 | * @param mixed $id |
109 | * @param bool $asArray |
110 | * @throws Exception |
111 | * @return array|Record |
112 | */ |
113 | public static function fetch(mixed $id, bool $asArray = true): array|Record |
114 | { |
115 | return (new static())->getById($id, $asArray); |
116 | } |
117 | |
118 | /** |
119 | * Create new |
120 | * |
121 | * @param array $data |
122 | * @param bool $asArray |
123 | * @throws Exception |
124 | * @return array|Record |
125 | */ |
126 | public static function createNew(array $data, bool $asArray = true): array|Record |
127 | { |
128 | return (new static())->create($data, $asArray); |
129 | } |
130 | |
131 | /** |
132 | * Filter by |
133 | * |
134 | * @param mixed $filters |
135 | * @param mixed $select |
136 | * @return static |
137 | */ |
138 | public static function filterBy(mixed $filters = null, mixed $select = null): static |
139 | { |
140 | return (new static())->filter($filters, $select); |
141 | } |
142 | |
143 | /** |
144 | * Get all |
145 | * |
146 | * @param ?string $sort |
147 | * @param mixed $limit |
148 | * @param mixed $page |
149 | * @param bool $asArray |
150 | * @throws Exception |
151 | * @return array|Collection |
152 | */ |
153 | public function getAll(?string $sort = null, mixed $limit = null, mixed $page = null, bool $asArray = true): array|Collection |
154 | { |
155 | $table = $this->getTableClass(); |
156 | $offsetAndLimit = $this->getOffsetAndLimit($page, $limit); |
157 | |
158 | if (!empty($this->options)) { |
159 | $this->options['offset'] = $offsetAndLimit['offset']; |
160 | $this->options['limit'] = $offsetAndLimit['limit']; |
161 | $this->options['order'] = $this->getOrderBy($sort); |
162 | } else { |
163 | $this->options = [ |
164 | 'offset' => $offsetAndLimit['offset'], |
165 | 'limit' => $offsetAndLimit['limit'], |
166 | 'order' => $this->getOrderBy($sort) |
167 | ]; |
168 | } |
169 | |
170 | if (!isset($this->options['select'])) { |
171 | if ($asArray) { |
172 | $this->options['select'] = $this->describe(); |
173 | } else { |
174 | $this->options = ['select' => $this->describe(true)]; |
175 | } |
176 | } |
177 | |
178 | if (!empty($this->foreignTables) && !isset($this->options['join'])) { |
179 | $this->options['join'] = $this->foreignTables; |
180 | } |
181 | |
182 | if (!empty($this->filters)) { |
183 | return $table::findBy($this->parseFilter($this->filters), $this->options, $asArray); |
184 | } else { |
185 | return $table::findAll($this->options, $asArray); |
186 | } |
187 | } |
188 | |
189 | /** |
190 | * Get by ID |
191 | * |
192 | * @param mixed $id |
193 | * @param bool $asArray |
194 | * @throws Exception |
195 | * @return array|Record |
196 | */ |
197 | public function getById(mixed $id, bool $asArray = true): array|Record |
198 | { |
199 | $table = $this->getTableClass(); |
200 | |
201 | if (!isset($this->options['select'])) { |
202 | if ($asArray) { |
203 | $this->options['select'] = $this->describe(); |
204 | } else { |
205 | $this->options = ['select' => $this->describe(true)]; |
206 | } |
207 | } |
208 | |
209 | if (!empty($this->foreignTables) && !isset($this->options['join'])) { |
210 | $this->options['join'] = $this->foreignTables; |
211 | } |
212 | |
213 | if (!empty($this->filters)) { |
214 | $primaryKeys = (new $table())->getPrimaryKeys(); |
215 | $tableClass = $table::table(); |
216 | foreach ($primaryKeys as $i => $primaryKey) { |
217 | if (is_array($id) && isset($id[$i])) { |
218 | $this->filters[] = $tableClass . '.' . $primaryKey . ' = ' . $id[$i]; |
219 | } else if (!is_array($id)) { |
220 | $this->filters[] = $tableClass . '.' . $primaryKey . ' = ' . $id; |
221 | } |
222 | } |
223 | return $table::findOne($this->parseFilter($this->filters), $this->options, $asArray); |
224 | } else { |
225 | return $table::findById($id, $this->options, $asArray); |
226 | } |
227 | } |
228 | |
229 | /** |
230 | * Create |
231 | * |
232 | * @param array $data |
233 | * @param bool $asArray |
234 | * @throws Exception |
235 | * @return array|Record |
236 | */ |
237 | public function create(array $data, bool $asArray = true): array|Record |
238 | { |
239 | if ($this->hasRequirements()) { |
240 | $results = $this->validate($data); |
241 | if (is_array($results)) { |
242 | return $results; |
243 | } |
244 | } |
245 | |
246 | $table = $this->getTableClass(); |
247 | $record = new $table($data); |
248 | $record->save(); |
249 | |
250 | return ($asArray) ? $record->toArray() : $record; |
251 | } |
252 | |
253 | /** |
254 | * Copy |
255 | * |
256 | * @param mixed $id |
257 | * @param array $replace |
258 | * @param bool $asArray |
259 | * @throws Exception |
260 | * @return array|Record |
261 | */ |
262 | public function copy(mixed $id, array $replace = [], bool $asArray = true): array|Record |
263 | { |
264 | $table = $this->getTableClass(); |
265 | $record = $table::findById($id); |
266 | $primaryKey = $this->getPrimaryId(); |
267 | |
268 | if (isset($record->{$primaryKey})) { |
269 | $record = $record->copy($replace); |
270 | } |
271 | |
272 | return ($asArray) ? $record->toArray() : $record; |
273 | } |
274 | |
275 | /** |
276 | * Replace |
277 | * |
278 | * @param mixed $id |
279 | * @param array $data |
280 | * @param bool $asArray |
281 | * @throws Exception |
282 | * @return array|Record |
283 | */ |
284 | public function replace(mixed $id, array $data, bool $asArray = true): array|Record |
285 | { |
286 | if ($this->hasRequirements()) { |
287 | $results = $this->validate($data); |
288 | if (is_array($results)) { |
289 | return $results; |
290 | } |
291 | } |
292 | |
293 | $table = $this->getTableClass(); |
294 | $record = $table::findById($id); |
295 | $recordData = $record->toArray(); |
296 | $primaryKey = $this->getPrimaryId(); |
297 | |
298 | if (isset($record->{$primaryKey})) { |
299 | foreach ($recordData as $key => $value) { |
300 | $record->{$key} = $data[$key] ?? null; |
301 | } |
302 | $record->save(); |
303 | } |
304 | |
305 | return ($asArray) ? $record->toArray() : $record; |
306 | } |
307 | |
308 | /** |
309 | * Update |
310 | * |
311 | * @param mixed $id |
312 | * @param array $data |
313 | * @param bool $asArray |
314 | * @throws Exception |
315 | * @return Record |
316 | */ |
317 | public function update(mixed $id, array $data, bool $asArray = true): array|Record |
318 | { |
319 | $table = $this->getTableClass(); |
320 | $record = $table::findById($id); |
321 | $primaryKey = $this->getPrimaryId(); |
322 | |
323 | if (isset($record->{$primaryKey})) { |
324 | foreach ($data as $key => $value) { |
325 | $record->{$key} = $value; |
326 | } |
327 | $record->save(); |
328 | } |
329 | |
330 | return ($asArray) ? $record->toArray() : $record; |
331 | } |
332 | |
333 | /** |
334 | * Delete |
335 | * |
336 | * @param mixed $id |
337 | * @throws Exception |
338 | * @return int |
339 | */ |
340 | public function delete(mixed $id): int |
341 | { |
342 | $table = $this->getTableClass(); |
343 | $record = $table::findById($id); |
344 | $primaryKey = $this->getPrimaryId(); |
345 | |
346 | if (isset($record->{$primaryKey})) { |
347 | $record->delete(); |
348 | return 1; |
349 | } else { |
350 | return 0; |
351 | } |
352 | } |
353 | |
354 | /** |
355 | * Remove multiple |
356 | * |
357 | * @param array $ids |
358 | * @throws Exception |
359 | * @return int |
360 | */ |
361 | public function remove(array $ids): int |
362 | { |
363 | $deleted = 0; |
364 | foreach ($ids as $id) { |
365 | $deleted += $this->delete($id); |
366 | } |
367 | return $deleted; |
368 | } |
369 | |
370 | /** |
371 | * Get count |
372 | * |
373 | * @throws Exception |
374 | * @return int |
375 | */ |
376 | public function count(): int |
377 | { |
378 | $options = $this->options; |
379 | |
380 | if (!empty($this->foreignTables) && !isset($options['join'])) { |
381 | $options['join'] = $this->foreignTables; |
382 | } |
383 | |
384 | if (isset($options['offset'])) { |
385 | unset($options['offset']); |
386 | } |
387 | |
388 | if (isset($options['limit'])) { |
389 | unset($options['limit']); |
390 | } |
391 | |
392 | $table = $this->getTableClass(); |
393 | if (!empty($this->filters)) { |
394 | return $table::getTotal($this->parseFilter($this->filters), $options); |
395 | } else { |
396 | return $table::getTotal(null, $options); |
397 | } |
398 | } |
399 | |
400 | /** |
401 | * Method to describe columns in the database table |
402 | * |
403 | * @param bool $native Show only the native columns in the table |
404 | * @param bool $full Used with the native flag, returns a full descriptive array of table info |
405 | * @return array |
406 | *@throws Exception |
407 | */ |
408 | public function describe(bool $native = false, bool $full = false): array |
409 | { |
410 | $table = $this->getTableClass(); |
411 | $tableInfo = $table::getTableInfo(); |
412 | $tableColumns = array_keys($tableInfo['columns']); |
413 | |
414 | if (!isset($tableInfo['tableName']) || !isset($tableInfo['columns'])) { |
415 | throw new Exception('Error: The table info parameter is not in the correct format'); |
416 | } |
417 | |
418 | $tableColumns = array_diff($tableColumns, $this->privateColumns); |
419 | |
420 | if ($native) { |
421 | return ($full) ? $tableInfo : $tableColumns; |
422 | } else{ |
423 | // Get any possible foreign columns |
424 | $foreignColumns = array_diff(array_diff($this->selectColumns, $tableColumns), $this->privateColumns); |
425 | |
426 | // Assemble and return allowed filtered columns |
427 | if (!empty($this->selectColumns)) { |
428 | $cols = []; |
429 | foreach ($this->selectColumns as $key => $column) { |
430 | if (in_array($column, $tableColumns) || in_array($column, $foreignColumns)) { |
431 | $cols[$key] = $column; |
432 | } |
433 | } |
434 | return $cols; |
435 | } else { |
436 | return array_values($tableColumns); |
437 | } |
438 | } |
439 | } |
440 | |
441 | /** |
442 | * Method to check if model has requirements |
443 | * |
444 | * @return bool |
445 | */ |
446 | public function hasRequirements(): bool |
447 | { |
448 | return !empty($this->requirements); |
449 | } |
450 | |
451 | /** |
452 | * Method to validate model data |
453 | * |
454 | * @param array $data |
455 | * @return bool|array |
456 | */ |
457 | public function validate(array $data): bool|array |
458 | { |
459 | $errors = []; |
460 | |
461 | foreach ($this->requirements as $column) { |
462 | if (!array_key_exists($column, $data)) { |
463 | $errors[$column] = "The column '" . $column . "' is required."; |
464 | } |
465 | } |
466 | |
467 | return (!empty($errors)) ? ['errors' => $errors] : true; |
468 | } |
469 | |
470 | /** |
471 | * Set filters |
472 | * |
473 | * @param mixed $filters |
474 | * @param mixed $select |
475 | * @param ?array $options |
476 | * @return AbstractDataModel |
477 | */ |
478 | public function filter(mixed $filters = null, mixed $select = null, ?array $options = null): AbstractDataModel |
479 | { |
480 | if (!empty($filters)) { |
481 | $this->filters = (!is_array($filters)) ? [$filters] : $filters; |
482 | } else { |
483 | $this->filters = []; |
484 | } |
485 | |
486 | $this->select($select, $options); |
487 | |
488 | return $this; |
489 | } |
490 | |
491 | /** |
492 | * Set (override) select columns |
493 | * |
494 | * @param mixed $select |
495 | * @param ?array $options |
496 | * @return AbstractDataModel |
497 | */ |
498 | public function select(mixed $select = null, ?array $options = null): AbstractDataModel |
499 | { |
500 | if (!empty($select)) { |
501 | if (is_string($select) && str_contains($select, ',')) { |
502 | $select = array_map('trim', explode(',', $select)); |
503 | } |
504 | if (empty($this->origSelectColumns)) { |
505 | $this->origSelectColumns = $this->selectColumns; |
506 | } |
507 | $select = (!is_array($select)) ? [$select] : $select; |
508 | $selectColumns = []; |
509 | |
510 | foreach ($select as $selectColumn) { |
511 | if (in_array($selectColumn, $this->selectColumns)) { |
512 | $selectColumns[array_search($selectColumn, $this->selectColumns)] = $selectColumn; |
513 | } else { |
514 | $selectColumns[] = $selectColumn; |
515 | } |
516 | } |
517 | |
518 | $this->selectColumns = $selectColumns; |
519 | } else if (!empty($this->origSelectColumns)) { |
520 | $this->selectColumns = $this->origSelectColumns; |
521 | } |
522 | |
523 | $this->options = (!empty($options)) ? $options : []; |
524 | |
525 | return $this; |
526 | } |
527 | |
528 | /** |
529 | * Get table class |
530 | * |
531 | * @throws Exception |
532 | * @return string |
533 | */ |
534 | public function getTableClass(): string |
535 | { |
536 | if (!empty($this->table) && class_exists($this->table)) { |
537 | return $this->table; |
538 | } |
539 | |
540 | $table = str_replace('Model', 'Table', get_class($this)); |
541 | if (!class_exists($table)) { |
542 | $table .= 's'; |
543 | } |
544 | if (!class_exists($table)) { |
545 | throw new Exception('Error: Unable to detect model table class'); |
546 | } |
547 | |
548 | return $table; |
549 | } |
550 | |
551 | /** |
552 | * Get table primary ID |
553 | * |
554 | * @return string |
555 | */ |
556 | public function getPrimaryId(): string |
557 | { |
558 | $table = $this->getTableClass(); |
559 | $primaryKeys = (new $table())->getPrimaryKeys(); |
560 | |
561 | return $primaryKeys[0] ?? 'id'; |
562 | } |
563 | |
564 | /** |
565 | * Get offset and limit |
566 | * |
567 | * @param mixed $page |
568 | * @param mixed $limit |
569 | * @return array |
570 | */ |
571 | public function getOffsetAndLimit(mixed $page = null, mixed $limit = null): array |
572 | { |
573 | if (($limit !== null) && ($page !== null)) { |
574 | $page = ((int)$page > 1) ? ($page * $limit) - $limit : null; |
575 | } else if ($limit !== null) { |
576 | $limit = (int)$limit; |
577 | } else { |
578 | $page = null; |
579 | $limit = null; |
580 | } |
581 | |
582 | return [ |
583 | 'offset' => $page, |
584 | 'limit' => $limit |
585 | ]; |
586 | } |
587 | |
588 | /** |
589 | * Get order by |
590 | * |
591 | * @param mixed $sort |
592 | * @param bool $asArray |
593 | * @return string|array|null |
594 | */ |
595 | public function getOrderBy(mixed $sort = null, bool $asArray = false): string|array|null |
596 | { |
597 | $orderBy = null; |
598 | $orderByStrings = []; |
599 | $orderByAry = []; |
600 | |
601 | if ($sort !== null) { |
602 | if (!is_array($sort)) { |
603 | $sort = (str_contains($sort, ',')) ? |
604 | explode(',', $sort) : [$sort]; |
605 | } |
606 | |
607 | foreach ($sort as $order) { |
608 | $order = trim($order); |
609 | if (str_starts_with($order, '-')) { |
610 | $orderByStrings[] = substr($order, 1) . ' DESC'; |
611 | $orderByAry[] = [ |
612 | 'by' => substr($order, 1), |
613 | 'order' => 'DESC' |
614 | ]; |
615 | } else { |
616 | $orderByStrings[] = $order . ' ASC'; |
617 | $orderByAry[] = [ |
618 | 'by' => $order, |
619 | 'order' => 'ASC' |
620 | ]; |
621 | } |
622 | } |
623 | |
624 | $orderBy = implode(', ', $orderByStrings); |
625 | } |
626 | |
627 | return ($asArray) ? $orderByAry : $orderBy; |
628 | } |
629 | |
630 | /** |
631 | * Method to parse filter for select predicates |
632 | * |
633 | * @param mixed $filter |
634 | * @return array |
635 | */ |
636 | public function parseFilter(mixed $filter): array |
637 | { |
638 | return (is_array($filter)) ? |
639 | Parser\Expression::convertExpressionsToShorthand($filter) : |
640 | Parser\Expression::convertExpressionToShorthand($filter); |
641 | } |
642 | |
643 | } |