Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
355 / 355
100.00% covered (success)
100.00%
61 / 61
CRAP
100.00% covered (success)
100.00%
1 / 1
Record
100.00% covered (success)
100.00%
355 / 355
100.00% covered (success)
100.00%
61 / 61
204
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
12
 newUnfilteredRecord
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 hasDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDb
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setDefaultDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 db
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 predicate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 table
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 start
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 commit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rollback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 transaction
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 findById
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findOne
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findOneOrCreate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 findLatest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 findBy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findByOrCreate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 findIn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 query
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 getTotal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getTableInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 with
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 getById
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getOne
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 getBy
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 getIn
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 getAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseColumns
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 hasOne
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 hasOneOf
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 hasMany
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
12
 belongsTo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 increment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 decrement
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replicate
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 copy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beforeSave
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 afterSave
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beforeInsert
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 afterInsert
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beforeUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 afterUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beforeDelete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 afterDelete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 delete
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 __callStatic
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 parseFindWhereArguments
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildWhereConditionColumns
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
14
 buildBetweenConditionColumns
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 parseBetweenValues
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
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;
16
17use Pop\Db\Record\Collection;
18use Pop\Db\Sql\PredicateSet;
19use Pop\Utils\CallableObject;
20
21/**
22 * Record class
23 *
24 * @category   Pop
25 * @package    Pop\Db
26 * @author     Nick Sagona, III <nick@popphp.org>
27 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
28 * @license    https://www.popphp.org/license     New BSD License
29 * @version    7.0.0
30 * @method     static findWhereEquals($column, $value, array $options = null, bool|array $toArray = false)
31 * @method     static findWhereNotEquals($column, $value, array $options = null, bool|array $toArray = false)
32 * @method     static findWhereGreaterThan($column, $value, array $options = null, bool|array $toArray = false)
33 * @method     static findWhereGreaterThanOrEqual($column, $value, array $options = null, bool|array $toArray = false)
34 * @method     static findWhereLessThan($column, $value, array $options = null, bool|array $toArray = false)
35 * @method     static findWhereLessThanOrEqual($column, $value, array $options = null, bool|array $toArray = false)
36 * @method     static findWhereLike($column, $value, array $options = null, bool|array $toArray = false)
37 * @method     static findWhereNotLike($column, $value, array $options = null, bool|array $toArray = false)
38 * @method     static findWhereIn($column, $values, array $options = null, bool|array $toArray = false)
39 * @method     static findWhereNotIn($column, $values, array $options = null, bool|array $toArray = false)
40 * @method     static findWhereBetween($column, $values, array $options = null, bool|array $toArray = false)
41 * @method     static findWhereNotBetween($column, $values, array $options = null, bool|array $toArray = false)
42 * @method     static findWhereNull($column, array $options = null, bool|array $toArray = false)
43 * @method     static findWhereNotNull($column, array $options = null, bool|array $toArray = false)
44 */
45class Record extends Record\AbstractRecord
46{
47
48    /**
49     * Constructor
50     *
51     * Instantiate the database record object
52     *
53     * Optional parameters are an array of column values, db adapter, or a table name
54
55     * @throws Exception|Record\Exception
56     */
57    public function __construct()
58    {
59        $args    = func_get_args();
60        $columns = null;
61        $table   = null;
62        $db      = null;
63        $class   = get_class($this);
64
65        foreach ($args as $arg) {
66            if (is_array($arg) || ($arg instanceof \ArrayAccess)) {
67                $columns = $arg;
68            } else if ($arg instanceof Adapter\AbstractAdapter) {
69                $db = $arg;
70            } else if (is_string($arg)) {
71                $table = $arg;
72            }
73        }
74
75        if ($table !== null) {
76            $this->setTable($table);
77        } else if ($this->table !== null) {
78            $this->setTable($this->table);
79        } else {
80            $this->setTableFromClassName($class);
81        }
82
83        if ($db !== null) {
84            Db::setDb($db, $class, null, ($class === __CLASS__));
85        }
86
87        if (!Db::hasDb($class)) {
88            throw new Exception('Error: A database connection has not been set.');
89        } else if (!Db::hasClassToTable($class)) {
90            Db::addClassToTable($class, $this->getFullTable());
91        }
92
93        $this->tableGateway = new Gateway\Table($this->getFullTable());
94        $this->rowGateway   = new Gateway\Row($this->getFullTable(), $this->primaryKeys);
95
96        if ($columns !== null) {
97            $this->isNew = true;
98            $this->fill($columns);
99        }
100    }
101
102/*
103 * Static methods
104 */
105
106    /**
107     * Create a new record instance from trusted, internally-sourced column data
108     *
109     * Mass-assignment filtering ($fillable/$guarded via fill()) is deliberately bypassed here.
110     * It exists to protect against untrusted external input being passed into the constructor,
111     * not against the component's own internal data flows (replicating an already-fetched
112     * record, or creating a record from the very search criteria that were just queried).
113     * Filtering those would silently drop column values and corrupt the resulting row.
114     *
115     * @param  ?array $columns
116     * @throws Exception|Record\Exception
117     * @return static
118     */
119    protected static function newUnfilteredRecord(?array $columns = null): static
120    {
121        $record = new static();
122
123        if ($columns !== null) {
124            $record->isNew = true;
125            $record->setColumns($columns);
126        }
127
128        return $record;
129    }
130
131    /**
132     * Check for a DB adapter
133     *
134     * @return bool
135     */
136    public static function hasDb(): bool
137    {
138        return Db::hasDb(get_called_class());
139    }
140
141    /**
142     * Set DB adapter
143     *
144     * @param  Adapter\AbstractAdapter $db
145     * @param  ?string                 $prefix
146     * @param  bool                    $isDefault
147     * @return void
148     */
149    public static function setDb(Adapter\AbstractAdapter $db, ?string $prefix = null, bool $isDefault = false): void
150    {
151        $class = get_called_class();
152        if ($class == 'Pop\Db\Record') {
153            Db::setDefaultDb($db);
154        } else {
155            Db::setDb($db, $class, $prefix, $isDefault);
156        }
157    }
158
159    /**
160     * Set DB adapter
161     *
162     * @param  Adapter\AbstractAdapter $db
163     * @return void
164     */
165    public static function setDefaultDb(Adapter\AbstractAdapter $db): void
166    {
167        Db::setDb($db, null, null, true);
168    }
169
170    /**
171     * Get DB adapter
172     *
173     * @return Adapter\AbstractAdapter
174     */
175    public static function getDb(): Adapter\AbstractAdapter
176    {
177        return Db::getDb(get_called_class());
178    }
179
180    /**
181     * Get DB adapter (alias)
182     *
183     * @return Adapter\AbstractAdapter
184     */
185    public static function db(): Adapter\AbstractAdapter
186    {
187        return Db::db(get_called_class());
188    }
189
190    /**
191     * Get SQL builder
192     *
193     * @return Sql
194     */
195    public static function getSql(): Sql
196    {
197        return Db::db(get_called_class())->createSql();
198    }
199
200    /**
201     * Get SQL builder (alias)
202     *
203     * @return Sql
204     */
205    public static function sql(): Sql
206    {
207        return Db::db(get_called_class())->createSql();
208    }
209
210    /**
211     * Get a predicate set
212     *
213     * @param  mixed   $predicates
214     * @param  ?string $conjunction
215     * @return PredicateSet
216     */
217    public static function predicate(mixed $predicates = null, ?string $conjunction = null): PredicateSet
218    {
219        return new PredicateSet(static::getSql(), $predicates, $conjunction);
220    }
221
222    /**
223     * Get table name
224     *
225     * @param  bool $quotes
226     * @return string
227     */
228    public static function table(bool $quotes = false): string
229    {
230        $table = (new static())->getFullTable();
231        $sql   = static::sql();
232        if ($quotes) {
233            $table = $sql->quoteId($table);
234        }
235        return $table;
236    }
237
238    /**
239     * Start transaction with the DB adapter. When called on a descendent class, construct
240     * a new object and use it for transaction management.
241     *
242     * @param mixed ...$constructorArgs Arguments passed to descendent class constructor
243     * @return static|null
244     * @throws Exception|Record\Exception
245     */
246    public static function start(mixed ...$constructorArgs): static|null
247    {
248        $class = get_called_class();
249
250        if ($class !== Record::class) {
251            $record = new static(...$constructorArgs);
252            $record->startTransaction();
253            return $record;
254        } else {
255            if (Db::hasDb($class)) {
256                Db::db($class)->beginTransaction();
257            }
258            return null;
259        }
260    }
261
262    /**
263     * Commit transaction with the DB adapter
264     *
265     * @throws Exception
266     * @return void
267     */
268    public static function commit(): void
269    {
270        $class = get_called_class();
271        if (Db::hasDb($class)) {
272            Db::db($class)->commit();
273        }
274    }
275
276    /**
277     * Rollback transaction with the DB adapter
278     *
279     * The adapter's transaction manager already handles nesting: at depth 1 this performs a real
280     * ROLLBACK, at any greater depth it rolls back to (and releases) that level's savepoint. Either
281     * way the level is left, so the connection is never stranded inside an open transaction.
282     *
283     * @param  \Exception|null $exception
284     * @throws Exception
285     * @return \Exception|null
286     */
287    public static function rollback(\Exception|null $exception = null): \Exception|null
288    {
289        $class = get_called_class();
290
291        if (Db::hasDb($class)) {
292            Db::db($class)->rollback();
293        }
294
295        return null;
296    }
297
298    /**
299     * Execute complete transaction with the DB adapter
300     *
301     * @param  mixed $callable
302     * @param  mixed $params
303     * @throws \Exception
304     * @return void
305     */
306    public static function transaction(mixed $callable, mixed $params = null): void
307    {
308        if (!($callable instanceof CallableObject)) {
309            $callable = new CallableObject($callable, $params);
310        }
311
312        try {
313            static::start();
314            $callable->call();
315            static::commit();
316        } catch (\Exception $e) {
317            $result = static::rollback($e);
318            throw (!empty($result)) ? $result : $e;
319        }
320    }
321
322    /**
323     * Find by ID static method
324     *
325     * @param  mixed  $id
326     * @param  ?array $options
327     * @param  bool   $toArray
328     * @return static|array
329     */
330    public static function findById(mixed $id, ?array $options = null, bool $toArray = false): array|static
331    {
332        return (new static())->getById($id, $options, $toArray);
333    }
334
335    /**
336     * Find one static method
337     *
338     * @param  array|PredicateSet|null $columns
339     * @param  ?array                  $options
340     * @param  bool                    $toArray
341     * @return static|array
342     */
343    public static function findOne(
344        array|PredicateSet|null $columns = null, ?array $options = null, bool $toArray = false
345    ): array|static
346    {
347        return (new static())->getOne($columns, $options, $toArray);
348    }
349
350    /**
351     * Find one or create static method
352     *
353     * @param  array|PredicateSet|null $columns
354     * @param  ?array                  $options
355     * @param  bool                    $toArray
356     * @return static|array
357     */
358    public static function findOneOrCreate(
359        array|PredicateSet|null $columns = null, ?array $options = null, bool $toArray = false
360    ): array|static
361    {
362        $result = (new static())->getOne($columns, $options);
363
364        if (empty($result->toArray())) {
365            if ($columns instanceof PredicateSet) {
366                $columns = $columns->extractValues();
367            }
368            $newRecord = static::newUnfilteredRecord($columns);
369            $newRecord->save();
370            $result = $newRecord;
371        }
372
373        return ($toArray) ? $result->toArray() : $result;
374    }
375
376    /**
377     * Find latest static method
378     *
379     * @param  ?string                 $by
380     * @param  array|PredicateSet|null $columns
381     * @param  ?array                  $options
382     * @param  bool                    $toArray
383     * @return static|array
384     */
385    public static function findLatest(
386        ?string $by = null, array|PredicateSet|null $columns = null, ?array $options = null, bool $toArray = false
387    ): array|static
388    {
389        $record = new static();
390
391        if (($by === null) && (count($record->getPrimaryKeys()) == 1)) {
392            $by = $record->getPrimaryKeys()[0];
393        }
394
395        if ($by !== null) {
396            if ($options === null) {
397                $options = ['order' => $by . ' DESC'];
398            } else {
399                $options['order'] = $by . ' DESC';
400            }
401        }
402
403        return $record->getOne($columns, $options, $toArray);
404    }
405
406    /**
407     * Find by static method
408     *
409     * @param  array|PredicateSet|null $columns
410     * @param  ?array                  $options
411     * @param  bool|array              $toArray
412     * @return Collection|array
413     */
414    public static function findBy(
415        array|PredicateSet|null $columns = null, ?array $options = null, bool|array $toArray = false
416    ): Collection|array
417    {
418        return (new static())->getBy($columns, $options, $toArray);
419    }
420
421    /**
422     * Find by or create static method
423     *
424     * @param  array|PredicateSet|null $columns
425     * @param  ?array                  $options
426     * @param  bool|array              $toArray
427     * @return static|Collection|array
428     */
429    public static function findByOrCreate(
430        array|PredicateSet|null $columns = null, ?array $options = null, bool|array $toArray = false
431    ): Collection|array|static
432    {
433        $result = (new static())->getBy($columns, $options);
434
435        if ($result->count() == 0) {
436            if ($columns instanceof PredicateSet) {
437                $columns = $columns->extractValues();
438            }
439            $newRecord = static::newUnfilteredRecord($columns);
440            $newRecord->save();
441            $result = $newRecord;
442            return ($toArray !== false) ? $result->toArray() : $result;
443        } else {
444            return ($toArray !== false) ? $result->toArray($toArray) : $result;
445        }
446    }
447
448    /**
449     * Find in static method
450     *
451     * @param  string                  $key
452     * @param  array                   $values
453     * @param  array|PredicateSet|null $columns
454     * @param  ?array                  $options
455     * @param  bool                    $toArray
456     * @return array
457     */
458    public static function findIn(
459        string $key, array $values, array|PredicateSet|null $columns = null, ?array $options = null, bool|array $toArray = false
460    ): array
461    {
462        return (new static())->getIn($key, $values, $columns, $options, $toArray);
463    }
464
465    /**
466     * Find all static method
467     *
468     * @param  ?array $options
469     * @param  bool   $toArray
470     * @return Collection|array|static
471     */
472    public static function findAll(?array $options = null, bool|array $toArray = false): Collection|array|static
473    {
474        return static::findBy(null, $options, $toArray);
475    }
476
477    /**
478     * Static method to execute a custom prepared SQL statement.
479     *
480     * @param  mixed $sql
481     * @param  array $params
482     * @param  bool  $toArray
483     * @return Collection|array|int
484     */
485    public static function execute(mixed $sql, array $params = [], bool|array $toArray = false): Collection|array|int
486    {
487        $record = new static();
488
489        if ($sql instanceof Sql) {
490            $sql = (string)$sql;
491        }
492
493        $db = Db::getDb($record->getFullTable());
494        $db->prepare($sql);
495        if (!empty($params)) {
496            $db->bindParams($params);
497        }
498        $db->execute();
499
500        $rows     = [];
501        $isSelect = false;
502
503        if (strtoupper(substr($sql, 0, 6)) == 'SELECT') {
504            $isSelect = true;
505            $rows     = $db->fetchAll();
506            foreach ($rows as $i => $row) {
507                $rows[$i] = $record->processRow($row, $toArray);
508            }
509        }
510
511        if ($isSelect) {
512            $collection = new Record\Collection($rows);
513            return ($toArray !== false) ? $collection->toArray($toArray) : $collection;
514        } else {
515            return self::db()->getNumberOfAffectedRows();
516        }
517    }
518
519    /**
520     * Static method to execute a custom SQL query.
521     *
522     * @param  mixed $sql
523     * @param  bool  $toArray
524     * @return Collection|array|int
525     */
526    public static function query(mixed $sql, bool|array $toArray = false): Collection|array|int
527    {
528        $record = new static();
529
530        if ($sql instanceof Sql) {
531            $sql = (string)$sql;
532        }
533
534        $db = Db::getDb($record->getFullTable());
535        $db->query($sql);
536
537        $rows     = [];
538        $isSelect = false;
539
540        if (strtoupper(substr($sql, 0, 6)) == 'SELECT') {
541            $isSelect = true;
542            while (($row = $db->fetch())) {
543                $rows[] = $record->processRow($row, $toArray);
544            }
545        }
546
547        if ($isSelect) {
548            $collection = new Record\Collection($rows);
549            return ($toArray !== false) ? $collection->toArray($toArray) : $collection;
550        } else {
551            return self::db()->getNumberOfAffectedRows();
552        }
553    }
554
555    /**
556     * Static method to get the total count of a set from the DB table
557     *
558     * @param  array|PredicateSet|null $columns
559     * @param  ?array                  $options
560     * @param  mixed                   $count
561     * @return int
562     */
563    public static function getTotal(array|PredicateSet|null $columns = null, ?array $options = null, mixed $count = 'COUNT(1)'): int
564    {
565        $record      = new static();
566        $expressions = null;
567        $params      = null;
568
569        if ($columns !== null) {
570            ['expressions' => $expressions, 'params' => $params] = $record->parseColumns($columns);
571        }
572
573        $rows = $record->getTableGateway()->select(['total_count' => $count], $expressions, $params, $options);
574
575        return (isset($rows[0]) && isset($rows[0]['total_count'])) ? (int)$rows[0]['total_count'] : 0;
576    }
577
578    /**
579     * Static method to get the total count of a set from the DB table
580     *
581     * @return array
582     */
583    public static function getTableInfo(): array
584    {
585        return (new static())->getTableGateway()->getTableInfo();
586    }
587
588    /**
589     * With a 1:many relationship (eager-loading)
590     *
591     * @param  mixed  $name
592     * @param  ?array $options
593     * @return static
594     */
595    public static function with(mixed $name, ?array $options = null): static
596    {
597        $record = new static();
598
599        if (is_array($name)) {
600            foreach ($name as $key => $value) {
601                if (is_numeric($key) && is_string($value)) {
602                    $record->addWith($value);
603                } else if (!is_numeric($key) && is_array($value)) {
604                    $record->addWith($key, $value);
605                }
606            }
607        } else {
608            $record->addWith($name, $options);
609        }
610
611        return $record;
612    }
613
614/*
615 * Instance methods
616 */
617
618    /**
619     * Get by ID method
620     *
621     * @param  mixed  $id
622     * @param  ?array $options
623     * @param  bool   $toArray
624     * @return static|array
625     */
626    public function getById(mixed $id, ?array $options = null, bool $toArray = false): Record|array|static
627    {
628        $this->setColumns($this->getRowGateway()->find($id, [], $options));
629        if ($this->hasWiths()) {
630            $this->getWithRelationships(false);
631        }
632        return ($toArray) ? $this->toArray() : $this;
633    }
634
635    /**
636     * Get one method
637     *
638     * @param  array|PredicateSet|null $columns
639     * @param  ?array                  $options
640     * @param  bool                    $toArray
641     * @return static|array
642     */
643    public function getOne(array|PredicateSet|null $columns = null, ?array $options = null, bool $toArray = false): Record|array|static
644    {
645        if ($options === null) {
646            $options = ['limit' => 1];
647        } else {
648            $options['limit'] = 1;
649        }
650
651        $expressions = null;
652        $params      = null;
653        $select      = $options['select'] ?? null;
654
655        if ($columns !== null) {
656            ['expressions' => $expressions, 'params' => $params] = $this->parseColumns($columns);
657        }
658
659        $rows = $this->getTableGateway()->select($select, $expressions, $params, $options);
660
661        foreach ($rows as $i => $row) {
662            $rows[$i] = $this->processRow($row);
663        }
664
665        if ($this->hasWiths() && !empty($rows)) {
666            $this->getWithRelationships();
667            $this->processWithRelationships($rows);
668        }
669
670        if (isset($rows[0])) {
671            $this->setColumns($rows[0]);
672            if ($rows[0]->hasRelationships()) {
673                $this->relationships = $rows[0]->getRelationships();
674            }
675        }
676
677        return ($toArray) ? $this->toArray() : $this;
678    }
679
680    /**
681     * Get by method
682     *
683     * @param  array|PredicateSet|null $columns
684     * @param  ?array                  $options
685     * @param  bool                    $toArray
686     * @return Collection|array
687     */
688    public function getBy(
689        array|PredicateSet|null $columns = null, ?array $options = null, bool|array $toArray = false
690    ): Collection|array
691    {
692        $expressions = null;
693        $params      = null;
694        $select      = $options['select'] ?? null;
695
696        if ($columns !== null) {
697            ['expressions' => $expressions, 'params' => $params] = $this->parseColumns($columns);
698        }
699
700        $rows = $this->getTableGateway()->select($select, $expressions, $params, $options);
701
702        foreach ($rows as $i => $row) {
703            $rows[$i] = $this->processRow($row);
704        }
705
706        if ($this->hasWiths() && !empty($rows)) {
707            $this->getWithRelationships();
708            $this->processWithRelationships($rows);
709        }
710
711        $collection = new Record\Collection($rows);
712        return ($toArray !== false) ? $collection->toArray($toArray) : $collection;
713    }
714
715    /**
716     * Get in method
717     *
718     * @param  string                  $key
719     * @param  array                   $values
720     * @param  array|PredicateSet|null $columns
721     * @param  ?array                  $options
722     * @param  bool                    $toArray
723     * @return array
724     */
725    public function getIn(
726        string $key, array $values, array|PredicateSet|null $columns = null, ?array $options = null, bool|array $toArray = false
727    ): array
728    {
729        $columns = (($columns !== null) && is_array($columns)) ?
730            array_merge([$key => ['IN', $values]], $columns) : [$key => ['IN', $values]];
731        $results = $this->getBy($columns, $options, $toArray);
732        $rows    = [];
733
734        foreach ($results as $row) {
735            if (isset($row[$key])) {
736                $rows[$row[$key]] = (($toArray !== false) && ($row instanceof Record)) ? $row->toArray() : $row;
737            }
738        }
739
740        return $rows;
741    }
742
743    /**
744     * Get all method
745     *
746     * @param  ?array $options
747     * @param  bool   $toArray
748     * @return Collection|array
749     */
750    public function getAll(?array $options = null, bool|array $toArray = false): Collection|array
751    {
752        return $this->getBy(null, $options, $toArray);
753    }
754
755    /**
756     * Parse columns and return expressions and params
757     *
758     * @param  array|PredicateSet $columns
759     * @return array
760     */
761    public function parseColumns(array|PredicateSet $columns): array
762    {
763        $expressions = null;
764        $params      = null;
765
766        if (is_array($columns)) {
767            $db           = Db::getDb($this->getFullTable());
768            $sql          = $db->createSql();
769            $predicateSet = Sql\Parser\Condition::parse($columns, $sql);
770            $expressions  = $predicateSet;
771            $params       = ($predicateSet->hasParameters()) ? $predicateSet->getParameters() : null;
772        } else {
773            $expressions = $columns;
774            $params      = ($columns->hasParameters()) ? $columns->getParameters() : null;
775        }
776
777        return ['expressions' => $expressions, 'params' => $params];
778    }
779
780    /**
781     * Has one relationship
782     *
783     * @param  string $foreignTable
784     * @param  string|array $foreignKey
785     * @param  ?array $options
786     * @param  bool   $eager
787     * @return Record|Record\Relationships\HasOne
788     */
789    public function hasOne(
790        string $foreignTable, string|array $foreignKey, ?array $options = null, bool $eager = false
791    ): Record|Record\Relationships\HasOne
792    {
793        $relationship = new Record\Relationships\HasOne($this, $foreignTable, $foreignKey, $options);
794        if (!empty($this->withChildren) && !empty($this->withChildren[$this->currentWithIndex])) {
795            $relationship->setChildRelationships($this->withChildren[$this->currentWithIndex]);
796        }
797        return ($eager) ? $relationship : $relationship->getChild($options);
798    }
799
800    /**
801     * Has one of relationship
802     *
803     * @param  string $foreignTable
804     * @param  string|array $foreignKey
805     * @param  ?array $options
806     * @param  bool   $eager
807     * @return Record|Record\Relationships\HasOneOf
808     */
809    public function hasOneOf(
810        string $foreignTable, string|array $foreignKey, ?array $options = null, bool $eager = false
811    ): Record|Record\Relationships\HasOneOf
812    {
813        $relationship = new Record\Relationships\HasOneOf($this, $foreignTable, $foreignKey, $options);
814        if (!empty($this->withChildren) && !empty($this->withChildren[$this->currentWithIndex])) {
815            $relationship->setChildRelationships($this->withChildren[$this->currentWithIndex]);
816        }
817        return ($eager) ? $relationship : $relationship->getChild();
818    }
819
820    /**
821     * Has many relationship
822     *
823     * @param  string $foreignTable
824     * @param  string|array $foreignKey
825     * @param  ?array $options
826     * @param  bool   $eager
827     * @return mixed
828     */
829    public function hasMany(
830        string $foreignTable, string|array $foreignKey, ?array $options = null, bool $eager = false
831    ): mixed
832    {
833        if (($this->latest) || ($this->oldest)) {
834            if ($options !== null) {
835                $options['order'] = $this->relationshipSortBy . ' ' . (($this->latest) ? 'DESC' : 'ASC');
836                $options['limit'] = 1;
837            } else {
838                $options = [
839                    'order' => $this->relationshipSortBy . ' ' . (($this->latest) ? 'DESC' : 'ASC'),
840                    'limit' => 1
841                ];
842            }
843        }
844
845        $relationship = new Record\Relationships\HasMany($this, $foreignTable, $foreignKey, $options);
846        if (!empty($this->withChildren) && !empty($this->withChildren[$this->currentWithIndex])) {
847            $relationship->setChildRelationships($this->withChildren[$this->currentWithIndex]);
848        }
849
850        if ($eager) {
851            return $relationship;
852        } else {
853            $children = $relationship->getChildren($options);
854            return ((($this->latest) || ($this->oldest)) && (count($children) == 1)) ? $children[0] : $children;
855        }
856    }
857
858    /**
859     * Belongs to relationship
860     *
861     * @param  string $foreignTable
862     * @param  string|array $foreignKey
863     * @param  ?array $options
864     * @param  bool   $eager
865     * @return Record|Record\Relationships\BelongsTo
866     */
867    public function belongsTo(
868        string $foreignTable, string|array $foreignKey, ?array $options = null, bool $eager = false
869    ): Record|Record\Relationships\BelongsTo
870    {
871        $relationship = new Record\Relationships\BelongsTo($this, $foreignTable, $foreignKey, $options);
872        if (!empty($this->withChildren) && !empty($this->withChildren[$this->currentWithIndex])) {
873            $relationship->setChildRelationships($this->withChildren[$this->currentWithIndex]);
874        }
875        return ($eager) ? $relationship : $relationship->getParent($options);
876    }
877
878    /**
879     * Increment the record column and save
880     *
881     * @param  string $column
882     * @param  int    $amount
883     * @return void
884     */
885    public function increment(string $column, int $amount = 1): void
886    {
887        $this->{$column} += (int)$amount;
888        $this->save();
889    }
890
891    /**
892     * Reset/clear the record column and save
893     *
894     * @param  string $column
895     * @param  mixed  $value
896     * @return void
897     */
898    public function reset(string $column, mixed $value = null): void
899    {
900        $this->{$column} = $value;
901        $this->save();
902    }
903
904    /**
905     * Decrement the record column and save
906     *
907     * @param  string $column
908     * @param  int    $amount
909     * @return void
910     */
911    public function decrement(string $column, int $amount = 1): void
912    {
913        $this->{$column} -= (int)$amount;
914        $this->save();
915    }
916
917    /**
918     * Replicate the record
919     *
920     * @param  array $replace
921     * @return static
922     */
923    public function replicate(array $replace = []): static
924    {
925        $fields = $this->toArray();
926
927        foreach ($this->primaryKeys as $key) {
928            if (isset($fields[$key])) {
929                unset($fields[$key]);
930            }
931        }
932
933        if (!empty($replace)) {
934            foreach ($replace as $key => $value) {
935                if (array_key_exists($key, $fields)) {
936                    $fields[$key] = $value;
937                }
938            }
939        }
940
941        $newRecord = static::newUnfilteredRecord($fields);
942        $newRecord->save();
943
944        return $newRecord;
945    }
946
947    /**
948     * Copy the record (alias to replicate)
949     *
950     * @param  array $replace
951     * @return static
952     */
953    public function copy(array $replace = []): static
954    {
955        return $this->replicate($replace);
956    }
957
958    /**
959     * Check if row is dirty
960     *
961     * @return bool
962     */
963    public function isDirty(): bool
964    {
965        return $this->rowGateway->isDirty();
966    }
967
968    /**
969     * Get row's dirty columns
970     *
971     * @return array
972     */
973    public function getDirty(): array
974    {
975        return $this->rowGateway->getDirty();
976    }
977
978    /**
979     * Reset row's dirty columns
980     *
981     * @return void
982     */
983    public function resetDirty(): void
984    {
985        $this->rowGateway->resetDirty();
986    }
987
988    /**
989     * Called before a single-record save() (both insert and update)
990     *
991     * @return void
992     */
993    protected function beforeSave(): void
994    {
995    }
996
997    /**
998     * Called after a single-record save() (both insert and update)
999     *
1000     * @return void
1001     */
1002    protected function afterSave(): void
1003    {
1004    }
1005
1006    /**
1007     * Called before a single-record insert (a new record being saved for the first time)
1008     *
1009     * @return void
1010     */
1011    protected function beforeInsert(): void
1012    {
1013    }
1014
1015    /**
1016     * Called after a single-record insert
1017     *
1018     * @return void
1019     */
1020    protected function afterInsert(): void
1021    {
1022    }
1023
1024    /**
1025     * Called before a single-record update (an existing record being saved again)
1026     *
1027     * @return void
1028     */
1029    protected function beforeUpdate(): void
1030    {
1031    }
1032
1033    /**
1034     * Called after a single-record update
1035     *
1036     * @return void
1037     */
1038    protected function afterUpdate(): void
1039    {
1040    }
1041
1042    /**
1043     * Called before a single-record delete
1044     *
1045     * @return void
1046     */
1047    protected function beforeDelete(): void
1048    {
1049    }
1050
1051    /**
1052     * Called after a single-record delete
1053     *
1054     * @return void
1055     */
1056    protected function afterDelete(): void
1057    {
1058    }
1059
1060    /**
1061     * Save or update the record
1062     *
1063     * @param  ?array $columns
1064     * @param  bool   $commit
1065     * @throws \Exception
1066     * @return void
1067     */
1068    public function save(?array $columns = null, bool $commit = true): void
1069    {
1070        try {
1071            // Save or update the record
1072            if ($columns === null) {
1073                $this->beforeSave();
1074                if ($this->isNew) {
1075                    $this->beforeInsert();
1076                    $this->rowGateway->save();
1077                    $this->isNew = false;
1078                    $this->afterInsert();
1079                } else {
1080                    $this->beforeUpdate();
1081                    $this->rowGateway->update();
1082                    $this->getById($this->rowGateway->getPrimaryValues());
1083                    $this->afterUpdate();
1084                }
1085                $this->afterSave();
1086            // Else, save multiple rows
1087            } else {
1088                if (isset($columns[0])) {
1089                    $this->tableGateway->insertRows($columns);
1090                } else {
1091                    $this->tableGateway->insert($columns);
1092                }
1093            }
1094            if (($this->isTransaction()) && ($commit)) {
1095                $this->commitTransaction();
1096            }
1097        } catch (\Exception $e) {
1098            if (($this->isTransaction()) && ($commit)) {
1099                $this->rollbackTransaction();
1100            }
1101            throw $e;
1102        }
1103    }
1104
1105    /**
1106     * Delete the record
1107     *
1108     * @param  ?array $columns
1109     * @param  bool   $commit
1110     * @return void
1111     */
1112    public function delete(?array $columns = null, bool $commit = true): void
1113    {
1114        try {
1115            // Delete the record
1116            if ($columns === null) {
1117                $this->beforeDelete();
1118                // The row gateway clears its own columns/primary values as part of delete(),
1119                // so restore them afterward (then re-clear once afterDelete() has had a chance
1120                // to run) - this lets afterDelete() still read the just-deleted record's data.
1121                $deletedColumns = $this->rowGateway->getColumns();
1122                $this->rowGateway->delete();
1123                $this->rowGateway->setColumns($deletedColumns);
1124                try {
1125                    $this->afterDelete();
1126                } finally {
1127                    // Whether afterDelete() throws or returns normally, the DELETE has already
1128                    // run - clear the restored state back out so the record consistently reads
1129                    // as deleted (matching its actual state in the database) rather than a throw
1130                    // leaving columns populated while primaryValues is empty.
1131                    $this->rowGateway->setColumns([]);
1132                    $this->rowGateway->setPrimaryValues([]);
1133                }
1134            // Delete multiple rows
1135            } else {
1136                ['expressions' => $expressions, 'params' => $params] = $this->parseColumns($columns);
1137
1138                $this->tableGateway->delete($expressions, $params ?? []);
1139            }
1140
1141            $this->setRows();
1142            $this->setColumns();
1143
1144            if (($this->isTransaction()) && ($commit)) {
1145                $this->commitTransaction();
1146            }
1147        } catch (\Exception $e) {
1148            if (($this->isTransaction()) && ($commit)) {
1149                $this->rollbackTransaction();
1150            }
1151            throw $e;
1152        }
1153
1154    }
1155
1156    /**
1157     * Call static method for 'findWhere'
1158     *
1159     *     $users = Users::findWhereUsername($value);
1160     *
1161     *     $users = Users::findWhereEquals($column, $value);
1162     *     $users = Users::findWhereNotEquals($column, $value);
1163     *     $users = Users::findWhereGreaterThan($column, $value);
1164     *     $users = Users::findWhereGreaterThanOrEqual($column, $value);
1165     *     $users = Users::findWhereLessThan($column, $value);
1166     *     $users = Users::findWhereLessThanOrEqual($column, $value);
1167     *
1168     *     $users = Users::findWhereLike($column, $value);
1169     *     $users = Users::findWhereNotLike($column, $value);
1170     *
1171     *     $users = Users::findWhereIn($column, $values);
1172     *     $users = Users::findWhereNotIn($column, $values);
1173     *
1174     *     $users = Users::findWhereBetween($column, $values);
1175     *     $users = Users::findWhereNotBetween($column, $values);
1176     *
1177     *     $users = Users::findWhereNull($column);
1178     *     $users = Users::findWhereNotNull($column);
1179     *
1180     * @param  string $name
1181     * @param  array  $arguments
1182     * @return Collection|array|null
1183     */
1184    public static function __callStatic(string $name, array $arguments): Collection|array|null
1185    {
1186        $columns    = null;
1187        $options    = null;
1188        $toArray    = false;
1189        $conditions = [
1190            'Equals', 'NotEquals', 'GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual',
1191            'Like', 'NotLike', 'In', 'NotIn', 'Between', 'NotBetween', 'Null', 'NotNull'
1192        ];
1193
1194        if (str_starts_with($name, 'findWhere')) {
1195            if (in_array(substr($name, 9), $conditions)) {
1196                $condition = substr($name, 9);
1197
1198                [$column, $value, $options, $toArray] = static::parseFindWhereArguments($condition, $arguments);
1199
1200                // These build structured shorthand tuples (see Sql\Parser\Condition) rather than
1201                // the deprecated suffixed-key shapes, so that calling one of these documented
1202                // methods never fires an E_USER_DEPRECATED notice the caller cannot avoid.
1203                $columns = static::buildWhereConditionColumns($condition, $column, $value);
1204            } else {
1205                $column  = Sql\Parser\Table::parse(substr($name, 9));
1206                $value   = $arguments[0] ?? null;
1207                $options = $arguments[1] ?? null;
1208                $toArray = $arguments[2] ?? false;
1209
1210                if ($value !== null) {
1211                    $columns = [$column => $value];
1212                }
1213            }
1214        }
1215
1216        return ($columns !== null) ? static::findBy($columns, $options, $toArray) : null;
1217    }
1218
1219    /**
1220     * Parse the (column, value, options, toArray) arguments for a findWhere*() call
1221     *
1222     * The 'Null'/'NotNull' conditions take no value argument, so $options and $toArray
1223     * shift down by one argument position relative to every other condition.
1224     *
1225     * @param  string $condition
1226     * @param  array  $arguments
1227     * @return array
1228     */
1229    protected static function parseFindWhereArguments(string $condition, array $arguments): array
1230    {
1231        $column = $arguments[0];
1232
1233        if (str_contains($condition, 'Null')) {
1234            return [$column, null, $arguments[1] ?? null, $arguments[2] ?? false];
1235        }
1236
1237        return [$column, $arguments[1], $arguments[2] ?? null, $arguments[3] ?? false];
1238    }
1239
1240    /**
1241     * Build the structured shorthand columns tuple for a findWhere*() condition
1242     *
1243     * @param  string $condition
1244     * @param  string $column
1245     * @param  mixed  $value
1246     * @return ?array
1247     */
1248    protected static function buildWhereConditionColumns(string $condition, string $column, mixed $value): ?array
1249    {
1250        return match ($condition) {
1251            // A bare key with a scalar value is plain equality; with a null value it
1252            // is IS NULL. Both are first-class structured shorthand already.
1253            'Equals', 'Null'     => [$column => $value],
1254            'NotEquals'          => [$column => ['!=', $value]],
1255            'GreaterThan'        => [$column => ['>', $value]],
1256            'GreaterThanOrEqual' => [$column => ['>=', $value]],
1257            'LessThan'           => [$column => ['<', $value]],
1258            'LessThanOrEqual'    => [$column => ['<=', $value]],
1259            // The structured LIKE tuple takes the full pattern as-is, so the value's
1260            // leading/trailing '%' no longer has to be moved onto the column key
1261            'Like'               => [$column => ['LIKE', $value]],
1262            'NotLike'            => [$column => ['NOT LIKE', $value]],
1263            'In'                 => [$column => ['IN', $value]],
1264            'NotIn'              => [$column => ['NOT IN', $value]],
1265            'Between', 'NotBetween' => static::buildBetweenConditionColumns($condition, $column, $value),
1266            'NotNull'            => [$column => ['IS NOT NULL']],
1267            default              => null,
1268        };
1269    }
1270
1271    /**
1272     * Build the structured shorthand columns tuple for a findWhereBetween()/findWhereNotBetween() condition
1273     *
1274     * @param  string $condition
1275     * @param  string $column
1276     * @param  mixed  $value
1277     * @return array
1278     */
1279    protected static function buildBetweenConditionColumns(string $condition, string $column, mixed $value): array
1280    {
1281        $operator = ($condition == 'NotBetween') ? 'NOT BETWEEN' : 'BETWEEN';
1282        $between  = static::parseBetweenValues($value);
1283
1284        return ($between !== null) ?
1285            [$column => [$operator, $between[0], $between[1]]] :
1286            // Unrecognized value shape - leave it to the legacy path, which is
1287            // what handled it before
1288            [$column . (($condition == 'NotBetween') ? '-' : '') => $value];
1289    }
1290
1291    /**
1292     * Normalize a findWhereBetween()/findWhereNotBetween() value into its two boundary values
1293     *
1294     * The documented calling convention packs both boundaries into a single string,
1295     * '(value1, value2)' or '(value1 AND value2)' - the same shape the legacy shorthand parser
1296     * accepts. A 2-element array is also accepted, and is the unambiguous form. Returns null if
1297     * the value matches neither shape, in which case the caller leaves it to the legacy path.
1298     *
1299     * @param  mixed $value
1300     * @return ?array
1301     */
1302    protected static function parseBetweenValues(mixed $value): ?array
1303    {
1304        if (is_array($value)) {
1305            return (count($value) == 2) ? array_values($value) : null;
1306        }
1307
1308        if (is_string($value) && str_starts_with($value, '(') && str_ends_with($value, ')')) {
1309            $values    = substr($value, 1, -1);
1310            $delimiter = (str_contains($values, ',')) ? ',' : 'AND';
1311            $values    = array_map('trim', explode($delimiter, $values));
1312
1313            return (count($values) == 2) ? $values : null;
1314        }
1315
1316        return null;
1317    }
1318
1319}