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