Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.56% |
161 / 186 |
|
73.91% |
17 / 23 |
CRAP | |
0.00% |
0 / 1 |
AbstractDataModel | |
86.56% |
161 / 186 |
|
73.91% |
17 / 23 |
114.00 | |
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 | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
6.40 | |||
getById | |
40.00% |
6 / 15 |
|
0.00% |
0 / 1 |
26.50 | |||
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 | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
9.76 | |||
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 (https://www.popphp.org/) |
4 | * |
5 | * @link https://github.com/popphp/popphp-framework |
6 | * @author Nick Sagona, III <dev@noladev.com> |
7 | * @copyright Copyright (c) 2009-2025 NOLA Interactive, LLC. |
8 | * @license https://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@noladev.com> |
26 | * @copyright Copyright (c) 2009-2025 NOLA Interactive, LLC. |
27 | * @license https://www.popphp.org/license New BSD License |
28 | * @version 4.3.5 |
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|array $toArray |
97 | * @throws Exception |
98 | * @return array|Collection |
99 | */ |
100 | public static function fetchAll(?string $sort = null, mixed $limit = null, mixed $page = null, bool|array $toArray = false): array|Collection |
101 | { |
102 | return (new static())->getAll($sort, $limit, $page, $toArray); |
103 | } |
104 | |
105 | /** |
106 | * Fetch by ID |
107 | * |
108 | * @param mixed $id |
109 | * @param bool $toArray |
110 | * @throws Exception |
111 | * @return array|Record |
112 | */ |
113 | public static function fetch(mixed $id, bool $toArray = false): array|Record |
114 | { |
115 | return (new static())->getById($id, $toArray); |
116 | } |
117 | |
118 | /** |
119 | * Create new |
120 | * |
121 | * @param array $data |
122 | * @param bool $toArray |
123 | * @throws Exception |
124 | * @return array|Record |
125 | */ |
126 | public static function createNew(array $data, bool $toArray = false): array|Record |
127 | { |
128 | return (new static())->create($data, $toArray); |
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|array $toArray |
150 | * @throws Exception |
151 | * @return array|Collection |
152 | */ |
153 | public function getAll(?string $sort = null, mixed $limit = null, mixed $page = null, bool|array $toArray = false): 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 | $this->options['select'] = $this->describe(($toArray !== false)); |
172 | } |
173 | |
174 | if (!empty($this->foreignTables) && !isset($this->options['join'])) { |
175 | $this->options['join'] = $this->foreignTables; |
176 | } |
177 | |
178 | if (!empty($this->filters)) { |
179 | return $table::findBy($this->parseFilter($this->filters), $this->options, $toArray); |
180 | } else { |
181 | return $table::findAll($this->options, $toArray); |
182 | } |
183 | } |
184 | |
185 | /** |
186 | * Get by ID |
187 | * |
188 | * @param mixed $id |
189 | * @param bool $toArray |
190 | * @throws Exception |
191 | * @return array|Record |
192 | */ |
193 | public function getById(mixed $id, bool $toArray = false): array|Record |
194 | { |
195 | $table = $this->getTableClass(); |
196 | |
197 | if (!isset($this->options['select'])) { |
198 | $this->options['select'] = $this->describe(($toArray !== false)); |
199 | } |
200 | |
201 | if (!empty($this->foreignTables) && !isset($this->options['join'])) { |
202 | $this->options['join'] = $this->foreignTables; |
203 | } |
204 | |
205 | if (!empty($this->filters)) { |
206 | $primaryKeys = (new $table())->getPrimaryKeys(); |
207 | $tableClass = $table::table(); |
208 | foreach ($primaryKeys as $i => $primaryKey) { |
209 | if (is_array($id) && isset($id[$i])) { |
210 | $this->filters[] = $tableClass . '.' . $primaryKey . ' = ' . $id[$i]; |
211 | } else if (!is_array($id)) { |
212 | $this->filters[] = $tableClass . '.' . $primaryKey . ' = ' . $id; |
213 | } |
214 | } |
215 | return $table::findOne($this->parseFilter($this->filters), $this->options, $toArray); |
216 | } else { |
217 | return $table::findById($id, $this->options, $toArray); |
218 | } |
219 | } |
220 | |
221 | /** |
222 | * Create |
223 | * |
224 | * @param array $data |
225 | * @param bool $toArray |
226 | * @throws Exception |
227 | * @return array|Record |
228 | */ |
229 | public function create(array $data, bool $toArray = false): array|Record |
230 | { |
231 | if ($this->hasRequirements()) { |
232 | $results = $this->validate($data); |
233 | if (is_array($results)) { |
234 | return $results; |
235 | } |
236 | } |
237 | |
238 | $table = $this->getTableClass(); |
239 | $record = new $table($data); |
240 | $record->save(); |
241 | |
242 | return ($toArray) ? $record->toArray() : $record; |
243 | } |
244 | |
245 | /** |
246 | * Copy |
247 | * |
248 | * @param mixed $id |
249 | * @param array $replace |
250 | * @param bool $toArray |
251 | * @throws Exception |
252 | * @return array|Record |
253 | */ |
254 | public function copy(mixed $id, array $replace = [], bool $toArray = false): array|Record |
255 | { |
256 | $table = $this->getTableClass(); |
257 | $record = $table::findById($id); |
258 | $primaryKey = $this->getPrimaryId(); |
259 | |
260 | if (isset($record->{$primaryKey})) { |
261 | $record = $record->copy($replace); |
262 | } |
263 | |
264 | return ($toArray) ? $record->toArray() : $record; |
265 | } |
266 | |
267 | /** |
268 | * Replace |
269 | * |
270 | * @param mixed $id |
271 | * @param array $data |
272 | * @param bool $toArray |
273 | * @throws Exception |
274 | * @return array|Record |
275 | */ |
276 | public function replace(mixed $id, array $data, bool $toArray = false): array|Record |
277 | { |
278 | if ($this->hasRequirements()) { |
279 | $results = $this->validate($data); |
280 | if (is_array($results)) { |
281 | return $results; |
282 | } |
283 | } |
284 | |
285 | $table = $this->getTableClass(); |
286 | $record = $table::findById($id); |
287 | $recordData = $record->toArray(); |
288 | $primaryKey = $this->getPrimaryId(); |
289 | |
290 | if (isset($record->{$primaryKey})) { |
291 | foreach ($recordData as $key => $value) { |
292 | $record->{$key} = $data[$key] ?? null; |
293 | } |
294 | $record->save(); |
295 | } |
296 | |
297 | return ($toArray) ? $record->toArray() : $record; |
298 | } |
299 | |
300 | /** |
301 | * Update |
302 | * |
303 | * @param mixed $id |
304 | * @param array $data |
305 | * @param bool $toArray |
306 | * @throws Exception |
307 | * @return array|Record |
308 | */ |
309 | public function update(mixed $id, array $data, bool $toArray = false): array|Record |
310 | { |
311 | $table = $this->getTableClass(); |
312 | $record = $table::findById($id); |
313 | $primaryKey = $this->getPrimaryId(); |
314 | |
315 | if (isset($record->{$primaryKey})) { |
316 | foreach ($data as $key => $value) { |
317 | $record->{$key} = $value; |
318 | } |
319 | $record->save(); |
320 | } |
321 | |
322 | return ($toArray) ? $record->toArray() : $record; |
323 | } |
324 | |
325 | /** |
326 | * Delete |
327 | * |
328 | * @param mixed $id |
329 | * @throws Exception |
330 | * @return int |
331 | */ |
332 | public function delete(mixed $id): int |
333 | { |
334 | $table = $this->getTableClass(); |
335 | $record = $table::findById($id); |
336 | $primaryKey = $this->getPrimaryId(); |
337 | |
338 | if (isset($record->{$primaryKey})) { |
339 | $record->delete(); |
340 | return 1; |
341 | } else { |
342 | return 0; |
343 | } |
344 | } |
345 | |
346 | /** |
347 | * Remove multiple |
348 | * |
349 | * @param array $ids |
350 | * @throws Exception |
351 | * @return int |
352 | */ |
353 | public function remove(array $ids): int |
354 | { |
355 | $deleted = 0; |
356 | foreach ($ids as $id) { |
357 | $deleted += $this->delete($id); |
358 | } |
359 | return $deleted; |
360 | } |
361 | |
362 | /** |
363 | * Get count |
364 | * |
365 | * @throws Exception |
366 | * @return int |
367 | */ |
368 | public function count(): int |
369 | { |
370 | $options = $this->options; |
371 | |
372 | if (!empty($this->foreignTables) && !isset($options['join'])) { |
373 | $options['join'] = $this->foreignTables; |
374 | } |
375 | |
376 | if (isset($options['offset'])) { |
377 | unset($options['offset']); |
378 | } |
379 | |
380 | if (isset($options['limit'])) { |
381 | unset($options['limit']); |
382 | } |
383 | |
384 | $table = $this->getTableClass(); |
385 | if (!empty($this->filters)) { |
386 | return $table::getTotal($this->parseFilter($this->filters), $options); |
387 | } else { |
388 | return $table::getTotal(null, $options); |
389 | } |
390 | } |
391 | |
392 | /** |
393 | * Method to describe columns in the database table |
394 | * |
395 | * @param bool $native Show only the native columns in the table |
396 | * @param bool $full Used with the native flag, returns a full descriptive array of table info |
397 | * @return array |
398 | *@throws Exception |
399 | */ |
400 | public function describe(bool $native = false, bool $full = false): array |
401 | { |
402 | $table = $this->getTableClass(); |
403 | $tableInfo = $table::getTableInfo(); |
404 | $tableColumns = array_keys($tableInfo['columns']); |
405 | $tableName = $tableInfo['tableName']; |
406 | |
407 | if (!isset($tableInfo['tableName']) || !isset($tableInfo['columns'])) { |
408 | throw new Exception('Error: The table info parameter is not in the correct format'); |
409 | } |
410 | |
411 | $tableColumns = array_diff($tableColumns, $this->privateColumns); |
412 | |
413 | if ($native) { |
414 | return ($full) ? $tableInfo : array_map(function($value) use ($tableName) { |
415 | return $tableName . '.' . $value; |
416 | }, $tableColumns); |
417 | } else { |
418 | // Get any possible foreign columns |
419 | $foreignColumns = array_diff(array_diff($this->selectColumns, $tableColumns), $this->privateColumns); |
420 | |
421 | // Assemble and return allowed filtered columns |
422 | if (!empty($this->selectColumns)) { |
423 | $cols = []; |
424 | foreach ($this->selectColumns as $key => $column) { |
425 | if (in_array($column, $tableColumns) || in_array($column, $foreignColumns)) { |
426 | $cols[$key] = $column; |
427 | } |
428 | } |
429 | return $cols; |
430 | } else { |
431 | return array_values($tableColumns); |
432 | } |
433 | } |
434 | } |
435 | |
436 | /** |
437 | * Method to check if model has requirements |
438 | * |
439 | * @return bool |
440 | */ |
441 | public function hasRequirements(): bool |
442 | { |
443 | return !empty($this->requirements); |
444 | } |
445 | |
446 | /** |
447 | * Method to validate model data |
448 | * |
449 | * @param array $data |
450 | * @return bool|array |
451 | */ |
452 | public function validate(array $data): bool|array |
453 | { |
454 | $errors = []; |
455 | |
456 | foreach ($this->requirements as $column) { |
457 | if (!array_key_exists($column, $data)) { |
458 | $errors[$column] = "The column '" . $column . "' is required."; |
459 | } |
460 | } |
461 | |
462 | return (!empty($errors)) ? ['errors' => $errors] : true; |
463 | } |
464 | |
465 | /** |
466 | * Set filters |
467 | * |
468 | * @param mixed $filters |
469 | * @param mixed $select |
470 | * @param ?array $options |
471 | * @return AbstractDataModel |
472 | */ |
473 | public function filter(mixed $filters = null, mixed $select = null, ?array $options = null): AbstractDataModel |
474 | { |
475 | if (!empty($filters)) { |
476 | $this->filters = (!is_array($filters)) ? [$filters] : $filters; |
477 | } else { |
478 | $this->filters = []; |
479 | } |
480 | |
481 | $this->select($select, $options); |
482 | |
483 | return $this; |
484 | } |
485 | |
486 | /** |
487 | * Set (override) select columns |
488 | * |
489 | * @param mixed $select |
490 | * @param ?array $options |
491 | * @return AbstractDataModel |
492 | */ |
493 | public function select(mixed $select = null, ?array $options = null): AbstractDataModel |
494 | { |
495 | if (!empty($select)) { |
496 | if (is_string($select) && str_contains($select, ',')) { |
497 | $select = array_map('trim', explode(',', $select)); |
498 | } |
499 | if (empty($this->origSelectColumns)) { |
500 | $this->origSelectColumns = $this->selectColumns; |
501 | } |
502 | $select = (!is_array($select)) ? [$select] : $select; |
503 | $selectColumns = []; |
504 | |
505 | foreach ($select as $selectColumn) { |
506 | if (in_array($selectColumn, $this->selectColumns)) { |
507 | $selectColumns[array_search($selectColumn, $this->selectColumns)] = $selectColumn; |
508 | } else { |
509 | $selectColumns[] = $selectColumn; |
510 | } |
511 | } |
512 | |
513 | $this->selectColumns = $selectColumns; |
514 | } else if (!empty($this->origSelectColumns)) { |
515 | $this->selectColumns = $this->origSelectColumns; |
516 | } |
517 | |
518 | $this->options = (!empty($options)) ? $options : []; |
519 | |
520 | return $this; |
521 | } |
522 | |
523 | /** |
524 | * Get table class |
525 | * |
526 | * @throws Exception |
527 | * @return string |
528 | */ |
529 | public function getTableClass(): string |
530 | { |
531 | if (!empty($this->table) && class_exists($this->table)) { |
532 | return $this->table; |
533 | } |
534 | |
535 | $table = str_replace('Model', 'Table', get_class($this)); |
536 | if (!class_exists($table)) { |
537 | $table .= 's'; |
538 | } |
539 | if (!class_exists($table)) { |
540 | throw new Exception('Error: Unable to detect model table class'); |
541 | } |
542 | |
543 | return $table; |
544 | } |
545 | |
546 | /** |
547 | * Get table primary ID |
548 | * |
549 | * @return string |
550 | */ |
551 | public function getPrimaryId(): string |
552 | { |
553 | $table = $this->getTableClass(); |
554 | $primaryKeys = (new $table())->getPrimaryKeys(); |
555 | |
556 | return $primaryKeys[0] ?? 'id'; |
557 | } |
558 | |
559 | /** |
560 | * Get offset and limit |
561 | * |
562 | * @param mixed $page |
563 | * @param mixed $limit |
564 | * @return array |
565 | */ |
566 | public function getOffsetAndLimit(mixed $page = null, mixed $limit = null): array |
567 | { |
568 | if (($limit !== null) && ($page !== null)) { |
569 | $page = ((int)$page > 1) ? ($page * $limit) - $limit : null; |
570 | } else if ($limit !== null) { |
571 | $limit = (int)$limit; |
572 | } else { |
573 | $page = null; |
574 | $limit = null; |
575 | } |
576 | |
577 | return [ |
578 | 'offset' => $page, |
579 | 'limit' => $limit |
580 | ]; |
581 | } |
582 | |
583 | /** |
584 | * Get order by |
585 | * |
586 | * @param mixed $sort |
587 | * @param bool $toArray |
588 | * @return string|array|null |
589 | */ |
590 | public function getOrderBy(mixed $sort = null, bool $toArray = false): string|array|null |
591 | { |
592 | $orderBy = null; |
593 | $orderByStrings = []; |
594 | $orderByAry = []; |
595 | |
596 | if ($sort !== null) { |
597 | if (!is_array($sort)) { |
598 | $sort = (str_contains($sort, ',')) ? |
599 | explode(',', $sort) : [$sort]; |
600 | } |
601 | |
602 | foreach ($sort as $order) { |
603 | $order = trim($order); |
604 | if (str_starts_with($order, '-')) { |
605 | $orderByStrings[] = substr($order, 1) . ' DESC'; |
606 | $orderByAry[] = [ |
607 | 'by' => substr($order, 1), |
608 | 'order' => 'DESC' |
609 | ]; |
610 | } else { |
611 | $orderByStrings[] = $order . ' ASC'; |
612 | $orderByAry[] = [ |
613 | 'by' => $order, |
614 | 'order' => 'ASC' |
615 | ]; |
616 | } |
617 | } |
618 | |
619 | $orderBy = implode(', ', $orderByStrings); |
620 | } |
621 | |
622 | return ($toArray) ? $orderByAry : $orderBy; |
623 | } |
624 | |
625 | /** |
626 | * Method to parse filter for select predicates |
627 | * |
628 | * @param mixed $filter |
629 | * @return array |
630 | */ |
631 | public function parseFilter(mixed $filter): array |
632 | { |
633 | return (is_array($filter)) ? |
634 | Parser\Expression::convertExpressionsToShorthand($filter) : |
635 | Parser\Expression::convertExpressionToShorthand($filter); |
636 | } |
637 | |
638 | } |