Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.63% covered (success)
86.63%
162 / 187
73.91% covered (success)
73.91%
17 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractDataModel
86.63% covered (success)
86.63%
162 / 187
73.91% covered (success)
73.91%
17 / 23
116.56
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
75.00% covered (success)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
7.77
 getById
41.18% covered (warning)
41.18%
7 / 17
0.00% covered (danger)
0.00%
0 / 1
30.35
 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
87.50% covered (success)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
9.16
 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 (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 */
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@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 */
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 $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}