Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.56% covered (success)
86.56%
161 / 186
73.91% covered (success)
73.91%
17 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractDataModel
86.56% covered (success)
86.56%
161 / 186
73.91% covered (success)
73.91%
17 / 23
114.00
0.00% covered (danger)
0.00%
0 / 1
 fetchAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createNew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filterBy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAll
77.78% covered (success)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
6.40
 getById
40.00% covered (warning)
40.00%
6 / 15
0.00% covered (danger)
0.00%
0 / 1
26.50
 create
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 copy
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 replace
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 update
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 remove
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 count
72.73% covered (success)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 describe
78.95% covered (success)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
9.76
 hasRequirements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 filter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 select
81.25% covered (success)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
10.66
 getTableClass
75.00% covered (success)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 getPrimaryId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getOffsetAndLimit
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 getOrderBy
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 parseFilter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
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 */
14namespace Pop\Model;
15
16use Pop\Db\Record;
17use Pop\Db\Record\Collection;
18use 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 */
30abstract 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}