Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
354 / 354
100.00% covered (success)
100.00%
63 / 63
CRAP
100.00% covered (success)
100.00%
1 / 1
Model
100.00% covered (success)
100.00%
354 / 354
100.00% covered (success)
100.00%
63 / 63
141
100.00% covered (success)
100.00%
1 / 1
 __call
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 convertCase
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getConnectionRead
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConnectionWrite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeTableName
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getPrimaryKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isProtectPrimaryKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReturnType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedFields
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isAutoTimestamps
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldCreated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldUpdated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTimestampFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguageInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkPrimaryKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 filterAllowedFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getDatabaseToRead
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDatabaseToWrite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 makePageLimitAndOffset
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 sanitizePageNumber
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 paginate
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 setPager
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getPagerUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPagerView
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPagerQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPagerAllowedQueries
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPager
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readBy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 findBy
n/a
0 / 0
n/a
0 / 0
1
 read
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 find
n/a
0 / 0
n/a
0 / 0
1
 readRow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 findRow
n/a
0 / 0
n/a
0 / 0
1
 findWithCache
n/a
0 / 0
n/a
0 / 0
1
 readWithCache
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 findAll
n/a
0 / 0
n/a
0 / 0
1
 list
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 makeEntity
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 makeArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getTimestamp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 timezone
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 createBy
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 checkMysqliException
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setDuplicateEntryError
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 updateCachedRow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 save
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 updateBy
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 replace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replaceBy
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteBy
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getValidation
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getValidationLabels
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getValidationMessages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getValidationRules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getValidationValidators
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCacheActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheTtl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheDataNotFound
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework MVC Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\MVC;
11
12use BadMethodCallException;
13use DateTime;
14use DateTimeZone;
15use Exception;
16use Framework\Cache\Cache;
17use Framework\Database\Database;
18use Framework\Database\Manipulation\Traits\Where;
19use Framework\Language\Language;
20use Framework\Pagination\Pager;
21use Framework\Validation\Debug\ValidationCollector;
22use Framework\Validation\FilesValidator;
23use Framework\Validation\Validation;
24use InvalidArgumentException;
25use JetBrains\PhpStorm\ArrayShape;
26use JetBrains\PhpStorm\Deprecated;
27use JetBrains\PhpStorm\Pure;
28use LogicException;
29use mysqli_sql_exception;
30use RuntimeException;
31use stdClass;
32
33/**
34 * Class Model.
35 *
36 * @package mvc
37 *
38 * @method false|int|string createById(array|Entity|stdClass $data) Create a new row and return the id.
39 * @method array|Entity|stdClass|null readById(int|string $id) Read a row by id.
40 * @method false|int|string updateById(int|string $id, array|Entity|stdClass $data) Update rows by id.
41 * @method false|int|string deleteById(int|string $id) Delete rows by id.
42 * @method false|int|string replaceById(int|string $id, array|Entity|stdClass $data) Replace rows by id.
43 */
44abstract class Model implements ModelInterface
45{
46    /**
47     * Database connection instance name for read operations.
48     *
49     * @var string
50     */
51    protected string $connectionRead = 'default';
52    /**
53     * Database connection instance name for write operations.
54     *
55     * @var string
56     */
57    protected string $connectionWrite = 'default';
58    /**
59     * Table name.
60     *
61     * @var string
62     */
63    protected string $table;
64    /**
65     * Table Primary Key.
66     *
67     * @var string
68     */
69    protected string $primaryKey = 'id';
70    /**
71     * Prevents Primary Key changes on INSERT and UPDATE.
72     *
73     * @var bool
74     */
75    protected bool $protectPrimaryKey = true;
76    /**
77     * Fetched item return type.
78     *
79     * Array, object or the classname of an Entity instance.
80     *
81     * @see Entity
82     *
83     * @var string
84     */
85    protected string $returnType = stdClass::class;
86    /**
87     * Allowed columns for INSERT and UPDATE.
88     *
89     * @var array<int,string>
90     */
91    protected array $allowedFields;
92    /**
93     * Auto set timestamp fields.
94     *
95     * @var bool
96     */
97    protected bool $autoTimestamps = false;
98    /**
99     * The timestamp field for 'created at' time when $autoTimestamps is true.
100     *
101     * @var string
102     */
103    protected string $fieldCreated = 'createdAt';
104    /**
105     * The timestamp field for 'updated at' time when $autoTimestamps is true.
106     *
107     * @var string
108     */
109    protected string $fieldUpdated = 'updatedAt';
110    /**
111     * The timestamp format used on database write operations.
112     *
113     * @var string
114     */
115    protected string $timestampFormat = 'Y-m-d H:i:s';
116    /**
117     * The Model Validation instance.
118     */
119    protected Validation $validation;
120    /**
121     * Validation field labels.
122     *
123     * @var array<string,string>
124     */
125    protected array $validationLabels;
126    /**
127     * Validation error messages.
128     *
129     * @var array<string,array<string,string>>
130     */
131    protected array $validationMessages;
132    /**
133     * Validation rules.
134     *
135     * @see Validation::setRules
136     *
137     * @var array<string,array<string>|string>
138     */
139    protected array $validationRules;
140    /**
141     * Validation Validators.
142     *
143     * @var array<int,string>
144     */
145    protected array $validationValidators = [
146        Validator::class,
147        FilesValidator::class,
148    ];
149    /**
150     * The Pager instance.
151     *
152     * Instantiated when calling the paginate method.
153     *
154     * @see Model::paginate
155     *
156     * @var Pager
157     */
158    protected Pager $pager;
159    /**
160     * Default pager view.
161     *
162     * @var string
163     */
164    protected string $pagerView;
165    /**
166     * @var string
167     */
168    protected string $pagerQuery;
169    /**
170     * @var array<string>
171     */
172    protected array $pagerAllowedQueries;
173    /**
174     * Pager URL.
175     *
176     * @var string
177     */
178    protected string $pagerUrl;
179    protected bool $cacheActive = false;
180    protected string $cacheInstance = 'default';
181    protected int $cacheTtl = 60;
182    protected int | string $cacheDataNotFound = 0;
183    protected string $languageInstance = 'default';
184    protected string $columnCase = 'camel';
185
186    /**
187     * @param string $method
188     * @param array<string,mixed> $arguments
189     *
190     * @return mixed
191     */
192    public function __call(string $method, array $arguments) : mixed
193    {
194        if (\str_starts_with($method, 'createBy')) {
195            $method = \substr($method, 8);
196            $method = $this->convertCase($method, $this->columnCase);
197            return $this->createBy($method, $arguments[0]); // @phpstan-ignore-line
198        }
199        if (\str_starts_with($method, 'readBy')) {
200            $method = \substr($method, 6);
201            $method = $this->convertCase($method, $this->columnCase);
202            return $this->readBy($method, $arguments[0]); // @phpstan-ignore-line
203        }
204        if (\str_starts_with($method, 'updateBy')) {
205            $method = \substr($method, 8);
206            $method = $this->convertCase($method, $this->columnCase);
207            return $this->updateBy($method, $arguments[0], $arguments[1]); // @phpstan-ignore-line
208        }
209        if (\str_starts_with($method, 'deleteBy')) {
210            $method = \substr($method, 8);
211            $method = $this->convertCase($method, $this->columnCase);
212            return $this->deleteBy($method, $arguments[0]); // @phpstan-ignore-line
213        }
214        if (\str_starts_with($method, 'replaceBy')) {
215            $method = \substr($method, 9);
216            $method = $this->convertCase($method, $this->columnCase);
217            return $this->replaceBy($method, $arguments[0], $arguments[1]); // @phpstan-ignore-line
218        }
219        // @codeCoverageIgnoreStart
220        if (\str_starts_with($method, 'findBy')) {
221            $method = \substr($method, 6);
222            $method = $this->convertCase($method, $this->columnCase);
223            return $this->findBy($method, $arguments[0]); // @phpstan-ignore-line
224        }
225        // @codeCoverageIgnoreEnd
226        $class = static::class;
227        if (\method_exists($this, $method)) {
228            throw new BadMethodCallException(
229                "Method not allowed: {$class}::{$method}"
230            );
231        }
232        throw new BadMethodCallException("Method not found: {$class}::{$method}");
233    }
234
235    /**
236     * Convert a value to specific case.
237     *
238     * @param string $value
239     * @param string $case camel, pascal or snake
240     *
241     * @return string The converted value
242     */
243    protected function convertCase(string $value, string $case) : string
244    {
245        if ($case === 'camel' || $case === 'pascal') {
246            $value = \preg_replace('/([a-z])([A-Z])/', '\\1 \\2', $value);
247            $value = \preg_replace('@[^a-zA-Z0-9\-_ ]+@', '', $value);
248            $value = \str_replace(['-', '_'], ' ', $value);
249            $value = \str_replace(' ', '', \ucwords(\strtolower($value)));
250            $value = \strtolower($value[0]) . \substr($value, 1);
251            return $case === 'camel' ? \lcfirst($value) : \ucfirst($value);
252        }
253        if ($case === 'snake') {
254            $value = \preg_replace('/([a-z])([A-Z])/', '\\1_\\2', $value);
255            return \strtolower($value);
256        }
257        throw new InvalidArgumentException('Invalid case: ' . $case);
258    }
259
260    #[Pure]
261    protected function getConnectionRead() : string
262    {
263        return $this->connectionRead;
264    }
265
266    #[Pure]
267    protected function getConnectionWrite() : string
268    {
269        return $this->connectionWrite;
270    }
271
272    protected function getTable() : string
273    {
274        return $this->table ??= $this->makeTableName();
275    }
276
277    protected function makeTableName() : string
278    {
279        $name = static::class;
280        $pos = \strrpos($name, '\\');
281        if ($pos) {
282            $name = \substr($name, $pos + 1);
283        }
284        if (\str_ends_with($name, 'Model')) {
285            $name = \substr($name, 0, -5);
286        }
287        return $name;
288    }
289
290    #[Pure]
291    protected function getPrimaryKey() : string
292    {
293        return $this->primaryKey;
294    }
295
296    #[Pure]
297    protected function isProtectPrimaryKey() : bool
298    {
299        return $this->protectPrimaryKey;
300    }
301
302    #[Pure]
303    protected function getReturnType() : string
304    {
305        return $this->returnType;
306    }
307
308    /**
309     * @return array<int,string>
310     */
311    protected function getAllowedFields() : array
312    {
313        if (empty($this->allowedFields)) {
314            throw new LogicException(
315                'Allowed fields not defined for database writes'
316            );
317        }
318        return $this->allowedFields;
319    }
320
321    #[Pure]
322    protected function isAutoTimestamps() : bool
323    {
324        return $this->autoTimestamps;
325    }
326
327    #[Pure]
328    protected function getFieldCreated() : string
329    {
330        return $this->fieldCreated;
331    }
332
333    #[Pure]
334    protected function getFieldUpdated() : string
335    {
336        return $this->fieldUpdated;
337    }
338
339    #[Pure]
340    protected function getTimestampFormat() : string
341    {
342        return $this->timestampFormat;
343    }
344
345    protected function getLanguageInstance() : string
346    {
347        return $this->languageInstance;
348    }
349
350    protected function getLanguage() : Language
351    {
352        return App::language($this->getLanguageInstance());
353    }
354
355    protected function checkPrimaryKey(int | string $id) : void
356    {
357        if (empty($id)) {
358            throw new InvalidArgumentException(
359                'Primary Key can not be empty'
360            );
361        }
362    }
363
364    /**
365     * @template T
366     *
367     * @param array<string,T> $data
368     *
369     * @return array<string,T>
370     */
371    protected function filterAllowedFields(array $data) : array
372    {
373        $fields = \array_intersect_key($data, \array_flip($this->getAllowedFields()));
374        if ($this->isProtectPrimaryKey() !== false
375            && \array_key_exists($this->getPrimaryKey(), $fields)
376        ) {
377            throw new LogicException(
378                'Protected Primary Key field can not be SET'
379            );
380        }
381        return $fields;
382    }
383
384    /**
385     * @see Model::$connectionRead
386     *
387     * @return Database
388     */
389    protected function getDatabaseToRead() : Database
390    {
391        return App::database($this->getConnectionRead());
392    }
393
394    /**
395     * @see Model::$connectionWrite
396     *
397     * @return Database
398     */
399    protected function getDatabaseToWrite() : Database
400    {
401        return App::database($this->getConnectionWrite());
402    }
403
404    /**
405     * A basic function to count rows in the table.
406     *
407     * @param array<array<mixed>> $where Array in this format: `[['id', '=', 25]]`
408     *
409     * @see Where
410     *
411     * @return int
412     */
413    public function count(array $where = []) : int
414    {
415        $select = $this->getDatabaseToRead()
416            ->select()
417            ->expressions([
418                'count' => static function () : string {
419                    return 'COUNT(*)';
420                },
421            ])
422            ->from($this->getTable());
423        foreach ($where as $args) {
424            $select->where(...$args);
425        }
426        return $select->run()->fetch()->count; // @phpstan-ignore-line
427    }
428
429    /**
430     * @param int $page
431     * @param int $perPage
432     *
433     * @see Model::paginate
434     *
435     * @return array<int,int|null>
436     */
437    #[ArrayShape([0 => 'int', 1 => 'int|null'])]
438    #[Pure]
439    protected function makePageLimitAndOffset(int $page, int $perPage = 10) : array
440    {
441        $page = $this->sanitizePageNumber($page);
442        $perPage = $this->sanitizePageNumber($perPage);
443        $page = $page <= 1 ? null : $page * $perPage - $perPage;
444        if ($page > \PHP_INT_MAX) {
445            $page = \PHP_INT_MAX;
446        }
447        if ($perPage === \PHP_INT_MAX && $page !== null) {
448            $page = \PHP_INT_MAX;
449        }
450        return [
451            $perPage,
452            $page,
453        ];
454    }
455
456    protected function sanitizePageNumber(int $number) : int
457    {
458        if ($number < 0) {
459            if ($number === \PHP_INT_MIN) {
460                $number++;
461            }
462            $number *= -1;
463        }
464        return $number;
465    }
466
467    /**
468     * A basic function to paginate all rows of the table.
469     *
470     * @param mixed $page The current page
471     * @param mixed $perPage Items per page
472     * @param array<string>|string $orderBy Order by columns
473     * @param string $orderByDirection asc or desc
474     * @param array<array<mixed>> $where Array in this format: `[['id', '=', 25]]`
475     *
476     * @see Where
477     *
478     * @return array<int,array<mixed>|Entity|stdClass>
479     */
480    public function paginate(
481        mixed $page,
482        mixed $perPage = 10,
483        array $where = [],
484        array | string $orderBy = null,
485        string $orderByDirection = 'asc',
486    ) : array {
487        $page = Pager::sanitize($page);
488        $perPage = Pager::sanitize($perPage);
489        $select = $this->getDatabaseToRead()
490            ->select()
491            ->from($this->getTable())
492            ->limit(...$this->makePageLimitAndOffset($page, $perPage));
493        if ($where) {
494            foreach ($where as $args) {
495                $select->where(...$args);
496            }
497        }
498        if ($orderBy !== null) {
499            $orderBy = (array) $orderBy;
500            $orderByDir = \strtolower($orderByDirection);
501            if ( ! \in_array($orderByDir, [
502                'asc',
503                'desc',
504            ])) {
505                throw new InvalidArgumentException(
506                    'Invalid ORDER BY direction: ' . $orderByDirection
507                );
508            }
509            $orderByDir === 'asc'
510                ? $select->orderByAsc(...$orderBy)
511                : $select->orderByDesc(...$orderBy);
512        }
513        $data = $select->run()->fetchArrayAll();
514        foreach ($data as &$row) {
515            $row = $this->makeEntity($row);
516        }
517        unset($row);
518        $this->setPager(new Pager($page, $perPage, $this->count($where)));
519        return $data;
520    }
521
522    /**
523     * Set the Pager.
524     *
525     * @param Pager $pager
526     *
527     * @return static
528     */
529    protected function setPager(Pager $pager) : static
530    {
531        $pager->setLanguage($this->getLanguage());
532        $temp = $this->getPagerQuery();
533        if (isset($temp)) {
534            $pager->setQuery($temp);
535        }
536        $temp = $this->getPagerUrl();
537        if (isset($temp)) {
538            $pager->setUrl($temp);
539        }
540        $temp = $this->getPagerAllowedQueries();
541        if (isset($temp)) {
542            $pager->setAllowedQueries($temp);
543        }
544        $temp = $this->getPagerView();
545        if (isset($temp)) {
546            $pager->setDefaultView($temp);
547        }
548        $this->pager = $pager;
549        return $this;
550    }
551
552    /**
553     * Get the custom URL to be used in the Pager.
554     *
555     * @return string|null
556     */
557    protected function getPagerUrl() : ?string
558    {
559        return $this->pagerUrl ?? null;
560    }
561
562    /**
563     * Get the custom view to be used in the Pager.
564     *
565     * @return string|null
566     */
567    protected function getPagerView() : ?string
568    {
569        return $this->pagerView ?? null;
570    }
571
572    /**
573     * Get the custom query to be used in the Pager.
574     *
575     * @return string|null
576     */
577    protected function getPagerQuery() : ?string
578    {
579        return $this->pagerQuery ?? null;
580    }
581
582    /**
583     * Get allowed queries to be used in the Pager.
584     *
585     * @return array<string>|null
586     */
587    protected function getPagerAllowedQueries() : ?array
588    {
589        return $this->pagerAllowedQueries ?? null;
590    }
591
592    /**
593     * Get the Pager.
594     *
595     * Allowed only after calling a method that sets the Pager.
596     *
597     * @see Model::paginate()
598     *
599     * @return Pager
600     */
601    public function getPager() : Pager
602    {
603        return $this->pager;
604    }
605
606    /**
607     * Read a row by column name and value.
608     *
609     * @param string $column
610     * @param int|string $value
611     *
612     * @since 3.6
613     *
614     * @return array<string,float|int|string|null>|Entity|stdClass|null
615     */
616    public function readBy(
617        string $column,
618        int | string $value
619    ) : array | Entity | stdClass | null {
620        if ($this->isCacheActive()) {
621            return $this->readWithCache($column, $value);
622        }
623        $data = $this->readRow($column, $value);
624        return $data ? $this->makeEntity($data) : null;
625    }
626
627    /**
628     * Find a row by column name and value.
629     *
630     * @param string $column
631     * @param int|string $value
632     *
633     * @deprecated
634     *
635     * @codeCoverageIgnore
636     *
637     * @return array<string,float|int|string|null>|Entity|stdClass|null
638     */
639    #[Deprecated(
640        reason: 'since MVC Library version 3.6, use readBy() instead',
641        replacement: '%class%->readBy(%parameter0%, %parameter1%)'
642    )]
643    public function findBy(
644        string $column,
645        int | string $value
646    ) : array | Entity | stdClass | null {
647        \trigger_error(
648            'Method ' . __METHOD__ . ' is deprecated',
649            \E_USER_DEPRECATED
650        );
651        return $this->readBy($column, $value);
652    }
653
654    /**
655     * Read a row based on Primary Key.
656     *
657     * @param int|string $id
658     *
659     * @since 3.6
660     *
661     * @return array<string,float|int|string|null>|Entity|stdClass|null The
662     * selected row as configured on $returnType property or null if row was
663     * not found
664     */
665    public function read(int | string $id) : array | Entity | stdClass | null
666    {
667        $this->checkPrimaryKey($id);
668        return $this->readBy($this->getPrimaryKey(), $id);
669    }
670
671    /**
672     * @param int|string $id
673     *
674     * @return array|Entity|float[]|int[]|null[]|stdClass|string[]|null
675     *
676     * @deprecated
677     *
678     * @codeCoverageIgnore
679     */
680    #[Deprecated(
681        reason: 'since MVC Library version 3.6, use read() instead',
682        replacement: '%class%->read(%parameter0%)'
683    )]
684    public function find(int | string $id) : array | Entity | stdClass | null
685    {
686        \trigger_error(
687            'Method ' . __METHOD__ . ' is deprecated',
688            \E_USER_DEPRECATED
689        );
690        return $this->read($id);
691    }
692
693    /**
694     * @param string $column
695     * @param int|string $value
696     *
697     * @since 3.6
698     *
699     * @return array<string,float|int|string|null>|null
700     */
701    protected function readRow(string $column, int | string $value) : array | null
702    {
703        return $this->getDatabaseToRead()
704            ->select()
705            ->from($this->getTable())
706            ->whereEqual($column, $value)
707            ->limit(1)
708            ->run()
709            ->fetchArray();
710    }
711
712    /**
713     * @param string $column
714     * @param int|string $value
715     *
716     * @return array<string,float|int|string|null>|null
717     *
718     * @deprecated
719     *
720     * @codeCoverageIgnore
721     */
722    #[Deprecated(
723        reason: 'since MVC Library version 3.6, use readRow() instead',
724        replacement: '%class%->readRow(%parameter0%, %parameter1%)'
725    )]
726    protected function findRow(string $column, int | string $value) : array | null
727    {
728        \trigger_error(
729            'Method ' . __METHOD__ . ' is deprecated',
730            \E_USER_DEPRECATED
731        );
732        return $this->readRow($column, $value);
733    }
734
735    /**
736     * @param string $column
737     * @param int|string $value
738     *
739     * @return array<string,float|int|string|null>|Entity|stdClass|null
740     *
741     * @deprecated
742     *
743     * @codeCoverageIgnore
744     */
745    #[Deprecated(
746        reason: 'since MVC Library version 3.6, use readWithCache() instead',
747        replacement: '%class%->readWithCache(%parameter0%, %parameter1%)'
748    )]
749    protected function findWithCache(string $column, int | string $value) : array | Entity | stdClass | null
750    {
751        \trigger_error(
752            'Method ' . __METHOD__ . ' is deprecated',
753            \E_USER_DEPRECATED
754        );
755        return $this->readWithCache($column, $value);
756    }
757
758    /**
759     * @param string $column
760     * @param int|string $value
761     *
762     * @since 3.6
763     *
764     * @return array<string,float|int|string|null>|Entity|stdClass|null
765     */
766    protected function readWithCache(string $column, int | string $value) : array | Entity | stdClass | null
767    {
768        $cacheKey = $this->getCacheKey([
769            $column => $value,
770        ]);
771        $data = $this->getCache()->get($cacheKey);
772        if ($data === $this->getCacheDataNotFound()) {
773            return null;
774        }
775        if (\is_array($data)) {
776            return $this->makeEntity($data);
777        }
778        $data = $this->readRow($column, $value);
779        if ($data === null) {
780            $data = $this->getCacheDataNotFound();
781        }
782        $this->getCache()->set($cacheKey, $data, $this->getCacheTtl());
783        return \is_array($data) ? $this->makeEntity($data) : null;
784    }
785
786    /**
787     * Find all rows with limit and offset.
788     *
789     * @param int|null $limit
790     * @param int|null $offset
791     *
792     * @return array<int,array<mixed>|Entity|stdClass>
793     *
794     * @deprecated
795     *
796     * @codeCoverageIgnore
797     */
798    #[Deprecated(
799        reason: 'since MVC Library version 3.6, use list() instead',
800        replacement: '%class%->readAll(%parameter0%, %parameter1%)'
801    )]
802    public function findAll(int $limit = null, int $offset = null) : array
803    {
804        \trigger_error(
805            'Method ' . __METHOD__ . ' is deprecated',
806            \E_USER_DEPRECATED
807        );
808        return $this->list($limit, $offset);
809    }
810
811    /**
812     * List rows, optionally with limit and offset.
813     *
814     * @param int|null $limit
815     * @param int|null $offset
816     *
817     * @since 3.6
818     *
819     * @return array<int,array<mixed>|Entity|stdClass>
820     */
821    public function list(int $limit = null, int $offset = null) : array
822    {
823        $data = $this->getDatabaseToRead()
824            ->select()
825            ->from($this->getTable());
826        if ($limit !== null) {
827            $data->limit($limit, $offset);
828        }
829        $data = $data->run()->fetchArrayAll();
830        foreach ($data as &$row) {
831            $row = $this->makeEntity($row);
832        }
833        unset($row);
834        return $data;
835    }
836
837    /**
838     * @param array<string,float|int|string|null> $data
839     *
840     * @return array<string,float|int|string|null>|Entity|stdClass
841     */
842    protected function makeEntity(array $data) : array | Entity | stdClass
843    {
844        $returnType = $this->getReturnType();
845        if ($returnType === 'array') {
846            return $data;
847        }
848        if ($returnType === 'object' || $returnType === stdClass::class) {
849            return (object) $data;
850        }
851        return new $returnType($data); // @phpstan-ignore-line
852    }
853
854    /**
855     * @param array<string,mixed>|Entity|stdClass $data
856     *
857     * @return array<string,mixed>
858     */
859    protected function makeArray(array | Entity | stdClass $data) : array
860    {
861        return $data instanceof Entity
862            ? $data->toModel()
863            : (array) $data;
864    }
865
866    /**
867     * Used to auto set the timestamp fields.
868     *
869     * @throws Exception if a DateTime error occur
870     *
871     * @return string The timestamp in the $timestampFormat property format
872     */
873    protected function getTimestamp() : string
874    {
875        return (new DateTime('now', $this->timezone()))->format(
876            $this->getTimestampFormat()
877        );
878    }
879
880    /**
881     * Get the timezone from database write connection config. As fallback, uses
882     * the UTC timezone.
883     *
884     * @throws Exception if database config has a bad timezone
885     *
886     * @return DateTimeZone
887     */
888    protected function timezone() : DateTimeZone
889    {
890        $timezone = $this->getDatabaseToWrite()->getConfig()['timezone'] ?? '+00:00';
891        return new DateTimeZone($timezone);
892    }
893
894    /**
895     * Insert a new row.
896     *
897     * @param array<string,float|int|string|null>|Entity|stdClass $data
898     *
899     * @return false|int|string The LAST_INSERT_ID() on success or false if
900     * validation fail
901     */
902    public function create(array | Entity | stdClass $data) : false | int | string
903    {
904        $data = $this->makeArray($data);
905        if ($this->getValidation()->validate($data) === false) {
906            return false;
907        }
908        $data = $this->filterAllowedFields($data);
909        if ($this->isAutoTimestamps()) {
910            $timestamp = $this->getTimestamp();
911            $data[$this->getFieldCreated()] ??= $timestamp;
912            $data[$this->getFieldUpdated()] ??= $timestamp;
913        }
914        $database = $this->getDatabaseToWrite();
915        try {
916            $affectedRows = $database->insert()
917                ->into($this->getTable())
918                ->set($data)
919                ->run();
920        } catch (mysqli_sql_exception $exception) {
921            $this->checkMysqliException($exception);
922            return false;
923        }
924        $insertId = $affectedRows > 0 // $affectedRows is -1 if fail with MYSQLI_REPORT_OFF
925            ? $database->insertId()
926            : false;
927        if ($insertId && $this->isCacheActive()) {
928            $this->updateCachedRow($this->getPrimaryKey(), $insertId);
929        }
930        return $insertId;
931    }
932
933    /**
934     * Insert a new row and return the inserted column value.
935     *
936     * @param string $column Column name
937     * @param array<string,float|int|string|null>|Entity|stdClass $data
938     *
939     * @return false|int|string The value from the column data or false if
940     * validation fail
941     */
942    public function createBy(string $column, array | Entity | stdClass $data) : false | int | string
943    {
944        $data = $this->makeArray($data);
945        if ($this->getValidation()->validate($data) === false) {
946            return false;
947        }
948        $data = $this->filterAllowedFields($data);
949        if ( ! isset($data[$column])) {
950            throw new LogicException('Value of column ' . $column . ' is not set');
951        }
952        if ($this->isAutoTimestamps()) {
953            $timestamp = $this->getTimestamp();
954            $data[$this->getFieldCreated()] ??= $timestamp;
955            $data[$this->getFieldUpdated()] ??= $timestamp;
956        }
957        try {
958            $this->getDatabaseToWrite()->insert()
959                ->into($this->getTable())
960                ->set($data)
961                ->run();
962        } catch (mysqli_sql_exception $exception) {
963            $this->checkMysqliException($exception);
964            return false;
965        }
966        if ($this->isCacheActive()) {
967            $this->updateCachedRow($column, $data[$column]);
968        }
969        return $data[$column];
970    }
971
972    /**
973     * @param mysqli_sql_exception $exception
974     *
975     * @throws mysqli_sql_exception if message is not for duplicate entry
976     */
977    protected function checkMysqliException(mysqli_sql_exception $exception) : void
978    {
979        $message = $exception->getMessage();
980        if (\str_starts_with($message, 'Duplicate entry')) {
981            $this->setDuplicateEntryError($message);
982            return;
983        }
984        throw $exception;
985    }
986
987    /**
988     * Set "Duplicate entry" as 'unique' error in the Validation.
989     *
990     * NOTE: We will get the index key name and not the column name. Usually the
991     * names are the same. If table have different column and index names,
992     * override this method and get the column name from the information_schema
993     * table.
994     *
995     * @param string $message The "Duplicate entry" message from the mysqli_sql_exception
996     */
997    protected function setDuplicateEntryError(string $message) : void
998    {
999        $field = \rtrim($message, "'");
1000        $field = \substr($field, \strrpos($field, "'") + 1);
1001        if ($field === 'PRIMARY') {
1002            $field = $this->getPrimaryKey();
1003        }
1004        $validation = $this->getValidation();
1005        $validation->setError($field, 'unique');
1006        $validation->getDebugCollector()
1007            ?->setErrorInDebugData($field, $validation->getError($field));
1008    }
1009
1010    /**
1011     * @param string $column
1012     * @param int|string $value
1013     */
1014    protected function updateCachedRow(string $column, int | string $value) : void
1015    {
1016        $data = $this->readRow($column, $value);
1017        if ($data === null) {
1018            $data = $this->getCacheDataNotFound();
1019        }
1020        $this->getCache()->set(
1021            $this->getCacheKey([$column => $value]),
1022            $data,
1023            $this->getCacheTtl()
1024        );
1025    }
1026
1027    /**
1028     * Save a row. Update if the Primary Key is present, otherwise
1029     * insert a new row.
1030     *
1031     * @param array<string,float|int|string|null>|Entity|stdClass $data
1032     *
1033     * @return false|int|string The number of affected rows on updates as int, the
1034     * LAST_INSERT_ID() as int on inserts or false if validation fails
1035     */
1036    public function save(array | Entity | stdClass $data) : false | int | string
1037    {
1038        $data = $this->makeArray($data);
1039        $id = $data[$this->getPrimaryKey()] ?? null;
1040        $data = $this->filterAllowedFields($data);
1041        if ($id !== null) {
1042            return $this->update($id, $data);
1043        }
1044        return $this->create($data);
1045    }
1046
1047    /**
1048     * Update based on Primary Key and return the number of affected rows.
1049     *
1050     * @param int|string $id
1051     * @param array<string,float|int|string|null>|Entity|stdClass $data
1052     *
1053     * @return false|int|string The number of affected rows or false if
1054     * validation fails
1055     */
1056    public function update(int | string $id, array | Entity | stdClass $data) : false | int | string
1057    {
1058        $this->checkPrimaryKey($id);
1059        return $this->updateBy($this->getPrimaryKey(), $id, $data);
1060    }
1061
1062    /**
1063     * Update based on column value and return the number of affected rows.
1064     *
1065     * @param string $column
1066     * @param int|string $value
1067     * @param array<string,float|int|string|null>|Entity|stdClass $data
1068     *
1069     * @return false|int|string The number of affected rows or false if
1070     * validation fails
1071     */
1072    public function updateBy(
1073        string $column,
1074        int | string $value,
1075        array | Entity | stdClass $data
1076    ) : false | int | string {
1077        $data = $this->makeArray($data);
1078        $data[$column] ??= $value;
1079        if ($this->getValidation()->validateOnly($data) === false) {
1080            return false;
1081        }
1082        $data = $this->filterAllowedFields($data);
1083        if ($this->isAutoTimestamps()) {
1084            $data[$this->getFieldUpdated()] ??= $this->getTimestamp();
1085        }
1086        try {
1087            $affectedRows = $this->getDatabaseToWrite()
1088                ->update()
1089                ->table($this->getTable())
1090                ->set($data)
1091                ->whereEqual($column, $value)
1092                ->run();
1093        } catch (mysqli_sql_exception $exception) {
1094            $this->checkMysqliException($exception);
1095            return false;
1096        }
1097        if ($this->isCacheActive()) {
1098            $this->updateCachedRow($column, $value);
1099        }
1100        return $affectedRows;
1101    }
1102
1103    /**
1104     * Replace based on Primary Key and return the number of affected rows.
1105     *
1106     * Most used with HTTP PUT method.
1107     *
1108     * @param int|string $id
1109     * @param array<string,float|int|string|null>|Entity|stdClass $data
1110     *
1111     * @return false|int|string The number of affected rows or false if
1112     * validation fails
1113     */
1114    public function replace(int | string $id, array | Entity | stdClass $data) : false | int | string
1115    {
1116        $this->checkPrimaryKey($id);
1117        return $this->replaceBy($this->getPrimaryKey(), $id, $data);
1118    }
1119
1120    /**
1121     * Replace based on column value and return the number of affected rows.
1122     *
1123     * @param string $column
1124     * @param int|string $value
1125     * @param array<string,float|int|string|null>|Entity|stdClass $data
1126     *
1127     * @return false|int|string The number of affected rows or false if
1128     * validation fails
1129     */
1130    public function replaceBy(
1131        string $column,
1132        int | string $value,
1133        array | Entity | stdClass $data
1134    ) : false | int | string {
1135        $data = $this->makeArray($data);
1136        $data[$column] ??= $value;
1137        if ($this->getValidation()->validate($data) === false) {
1138            return false;
1139        }
1140        $data = $this->filterAllowedFields($data);
1141        $data[$column] = $value;
1142        if ($this->isAutoTimestamps()) {
1143            $timestamp = $this->getTimestamp();
1144            $data[$this->getFieldCreated()] ??= $timestamp;
1145            $data[$this->getFieldUpdated()] ??= $timestamp;
1146        }
1147        $affectedRows = $this->getDatabaseToWrite()
1148            ->replace()
1149            ->into($this->getTable())
1150            ->set($data)
1151            ->run();
1152        if ($this->isCacheActive()) {
1153            $this->updateCachedRow($column, $value);
1154        }
1155        return $affectedRows;
1156    }
1157
1158    /**
1159     * Delete based on Primary Key.
1160     *
1161     * @param int|string $id
1162     *
1163     * @return false|int|string The number of affected rows
1164     */
1165    public function delete(int | string $id) : false | int | string
1166    {
1167        $this->checkPrimaryKey($id);
1168        return $this->deleteBy($this->getPrimaryKey(), $id);
1169    }
1170
1171    /**
1172     * Delete based on column value.
1173     *
1174     * @param string $column
1175     * @param int|string $value
1176     *
1177     * @return false|int|string The number of affected rows
1178     */
1179    public function deleteBy(string $column, int | string $value) : false | int | string
1180    {
1181        $affectedRows = $this->getDatabaseToWrite()
1182            ->delete()
1183            ->from($this->getTable())
1184            ->whereEqual($column, $value)
1185            ->run();
1186        if ($this->isCacheActive()) {
1187            $this->getCache()->delete(
1188                $this->getCacheKey([$column => $value])
1189            );
1190        }
1191        return $affectedRows;
1192    }
1193
1194    protected function getValidation() : Validation
1195    {
1196        if (isset($this->validation)) {
1197            return $this->validation;
1198        }
1199        $this->validation = new Validation(
1200            $this->getValidationValidators(),
1201            $this->getLanguage()
1202        );
1203        $this->validation->setRules($this->getValidationRules())
1204            ->setLabels($this->getValidationLabels())
1205            ->setMessages($this->getValidationMessages());
1206        if (App::isDebugging()) {
1207            $name = 'model ' . static::class;
1208            $collector = new ValidationCollector($name);
1209            App::debugger()->addCollector($collector, 'Validation');
1210            $this->validation->setDebugCollector($collector);
1211        }
1212        return $this->validation;
1213    }
1214
1215    /**
1216     * @return array<string,string>
1217     */
1218    protected function getValidationLabels() : array
1219    {
1220        return $this->validationLabels ?? [];
1221    }
1222
1223    /**
1224     * @return array<string,array<string,string>>
1225     */
1226    public function getValidationMessages() : array
1227    {
1228        return $this->validationMessages ?? [];
1229    }
1230
1231    /**
1232     * @return array<string,array<string>|string>
1233     */
1234    protected function getValidationRules() : array
1235    {
1236        if ( ! isset($this->validationRules)) {
1237            throw new RuntimeException('Validation rules are not set');
1238        }
1239        return $this->validationRules;
1240    }
1241
1242    /**
1243     * @return array<int,string>
1244     */
1245    public function getValidationValidators() : array
1246    {
1247        return $this->validationValidators;
1248    }
1249
1250    /**
1251     * Get Validation errors.
1252     *
1253     * @return array<string,string>
1254     */
1255    public function getErrors() : array
1256    {
1257        return $this->getValidation()->getErrors();
1258    }
1259
1260    #[Pure]
1261    protected function isCacheActive() : bool
1262    {
1263        return $this->cacheActive;
1264    }
1265
1266    #[Pure]
1267    protected function getCacheInstance() : string
1268    {
1269        return $this->cacheInstance;
1270    }
1271
1272    #[Pure]
1273    protected function getCacheTtl() : int
1274    {
1275        return $this->cacheTtl;
1276    }
1277
1278    #[Pure]
1279    protected function getCacheDataNotFound() : int | string
1280    {
1281        return $this->cacheDataNotFound;
1282    }
1283
1284    protected function getCache() : Cache
1285    {
1286        return App::cache($this->getCacheInstance());
1287    }
1288
1289    /**
1290     * @param array<string,float|int|string> $fields
1291     *
1292     * @return string
1293     */
1294    protected function getCacheKey(array $fields) : string
1295    {
1296        \ksort($fields);
1297        $suffix = [];
1298        foreach ($fields as $field => $value) {
1299            $suffix[] = $field . '=' . $value;
1300        }
1301        $suffix = \implode(';', $suffix);
1302        return 'Model:' . static::class . '::' . $suffix;
1303    }
1304}