• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 17516872682

06 Sep 2025 04:29PM UTC coverage: 84.283%. Remained the same
17516872682

Pull #9711

github

web-flow
Merge 11a325267 into e39c749b5
Pull Request #9711: refactor: Update types for `BaseModel`, `Model` and dependencies

30 of 30 new or added lines in 2 files covered. (100.0%)

15 existing lines in 1 file now uncovered.

21097 of 25031 relevant lines covered (84.28%)

194.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.26
/system/BaseModel.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter;
15

16
use Closure;
17
use CodeIgniter\Database\BaseConnection;
18
use CodeIgniter\Database\BaseResult;
19
use CodeIgniter\Database\Exceptions\DatabaseException;
20
use CodeIgniter\Database\Exceptions\DataException;
21
use CodeIgniter\Database\Query;
22
use CodeIgniter\DataConverter\DataConverter;
23
use CodeIgniter\Entity\Entity;
24
use CodeIgniter\Exceptions\InvalidArgumentException;
25
use CodeIgniter\Exceptions\ModelException;
26
use CodeIgniter\I18n\Time;
27
use CodeIgniter\Pager\Pager;
28
use CodeIgniter\Validation\ValidationInterface;
29
use Config\Feature;
30
use ReflectionClass;
31
use ReflectionException;
32
use ReflectionProperty;
33
use stdClass;
34

35
/**
36
 * The BaseModel class provides a number of convenient features that
37
 * makes working with a databases less painful. Extending this class
38
 * provide means of implementing various database systems.
39
 *
40
 * It will:
41
 *      - simplifies pagination
42
 *      - allow specifying the return type (array, object, etc) with each call
43
 *      - automatically set and update timestamps
44
 *      - handle soft deletes
45
 *      - ensure validation is run against objects when saving items
46
 *      - process various callbacks
47
 *      - allow intermingling calls to the db connection
48
 *
49
 * @phpstan-type row_array               array<int|string, float|int|null|object|string|bool>
50
 * @phpstan-type event_data_beforeinsert array{data: row_array}
51
 * @phpstan-type event_data_afterinsert  array{id: int|string, data: row_array, result: bool}
52
 * @phpstan-type event_data_beforefind   array{id?: int|string, method: string, singleton: bool, limit?: int, offset?: int}
53
 * @phpstan-type event_data_afterfind    array{id: int|string|null|list<int|string>, data: row_array|list<row_array>|object|null, method: string, singleton: bool}
54
 * @phpstan-type event_data_beforeupdate array{id: null|list<int|string>, data: row_array}
55
 * @phpstan-type event_data_afterupdate  array{id: null|list<int|string>, data: row_array|object, result: bool}
56
 * @phpstan-type event_data_beforedelete array{id: null|list<int|string>, purge: bool}
57
 * @phpstan-type event_data_afterdelete  array{id: null|list<int|string>, data: null, purge: bool, result: bool}
58
 */
59
abstract class BaseModel
60
{
61
    /**
62
     * Pager instance.
63
     *
64
     * Populated after calling `$this->paginate()`.
65
     *
66
     * @var Pager
67
     */
68
    public $pager;
69

70
    /**
71
     * Database Connection.
72
     *
73
     * @var BaseConnection
74
     */
75
    protected $db;
76

77
    /**
78
     * Last insert ID.
79
     *
80
     * @var int|string
81
     */
82
    protected $insertID = 0;
83

84
    /**
85
     * The Database connection group that
86
     * should be instantiated.
87
     *
88
     * @var non-empty-string|null
89
     */
90
    protected $DBGroup;
91

92
    /**
93
     * The format that the results should be returned as.
94
     *
95
     * Will be overridden if the `$this->asArray()`, `$this->asObject()` methods are used.
96
     *
97
     * @var 'array'|'object'|class-string
98
     */
99
    protected $returnType = 'array';
100

101
    /**
102
     * The temporary format of the result.
103
     *
104
     * Used by `$this->asArray()` and `$this->asObject()` to provide
105
     * temporary overrides of model default.
106
     *
107
     * @var 'array'|'object'|class-string
108
     */
109
    protected $tempReturnType;
110

111
    /**
112
     * Array of column names and the type of value to cast.
113
     *
114
     * @var array<string, string> Array order `['column' => 'type']`.
115
     */
116
    protected array $casts = [];
117

118
    /**
119
     * Custom convert handlers.
120
     *
121
     * @var array<string, class-string> Array order `['type' => 'classname']`.
122
     */
123
    protected array $castHandlers = [];
124

125
    protected ?DataConverter $converter = null;
126

127
    /**
128
     * Determines whether the model should protect field names during
129
     * mass assignment operations such as $this->insert(), $this->update().
130
     *
131
     * When set to `true`, only the fields explicitly defined in the `$allowedFields`
132
     * property will be allowed for mass assignment. This helps prevent
133
     * unintended modification of database fields and improves security
134
     * by avoiding mass assignment vulnerabilities.
135
     *
136
     * @var bool
137
     */
138
    protected $protectFields = true;
139

140
    /**
141
     * An array of field names that are allowed
142
     * to be set by the user in inserts/updates.
143
     *
144
     * @var list<string>
145
     */
146
    protected $allowedFields = [];
147

148
    /**
149
     * If true, will set created_at, and updated_at
150
     * values during insert and update routines.
151
     *
152
     * @var bool
153
     */
154
    protected $useTimestamps = false;
155

156
    /**
157
     * The type of column that created_at and updated_at
158
     * are expected to.
159
     *
160
     * @var 'date'|'datetime'|'int'
161
     */
162
    protected $dateFormat = 'datetime';
163

164
    /**
165
     * The column used for insert timestamps.
166
     *
167
     * @var string
168
     */
169
    protected $createdField = 'created_at';
170

171
    /**
172
     * The column used for update timestamps.
173
     *
174
     * @var string
175
     */
176
    protected $updatedField = 'updated_at';
177

178
    /**
179
     * If this model should use "softDeletes" and
180
     * simply set a date when rows are deleted, or
181
     * do hard deletes.
182
     *
183
     * @var bool
184
     */
185
    protected $useSoftDeletes = false;
186

187
    /**
188
     * Used by $this->withDeleted() to override the
189
     * model's "softDelete" setting.
190
     *
191
     * @var bool
192
     */
193
    protected $tempUseSoftDeletes;
194

195
    /**
196
     * The column used to save soft delete state.
197
     *
198
     * @var string
199
     */
200
    protected $deletedField = 'deleted_at';
201

202
    /**
203
     * Whether to allow inserting empty data.
204
     */
205
    protected bool $allowEmptyInserts = false;
206

207
    /**
208
     * Whether to update Entity's only changed data.
209
     */
210
    protected bool $updateOnlyChanged = true;
211

212
    /**
213
     * Rules used to validate data
214
     * in $this->insert(), $this->update(), $this->save() methods.
215
     *
216
     * The array must match the format of data passed to the `Validation`
217
     * library.
218
     *
219
     * @see https://codeigniter4.github.io/userguide/models/model.html#setting-validation-rules
220
     *
221
     * @var array<string, array<string, array<string, string>|string>|string>|string
222
     */
223
    protected $validationRules = [];
224

225
    /**
226
     * Contains any custom error messages to be
227
     * used during data validation.
228
     *
229
     * @var array<string, array<string, string>> The column is used as the keys.
230
     */
231
    protected $validationMessages = [];
232

233
    /**
234
     * Skip the model's validation.
235
     *
236
     * Used in conjunction with `$this->skipValidation()`
237
     * to skip data validation for any future calls.
238
     *
239
     * @var bool
240
     */
241
    protected $skipValidation = false;
242

243
    /**
244
     * Whether rules should be removed that do not exist
245
     * in the passed data. Used in updates.
246
     *
247
     * @var bool
248
     */
249
    protected $cleanValidationRules = true;
250

251
    /**
252
     * Our validator instance.
253
     *
254
     * @var ValidationInterface|null
255
     */
256
    protected $validation;
257

258
    /*
259
     * Callbacks.
260
     *
261
     * Each array should contain the method names (within the model)
262
     * that should be called when those events are triggered.
263
     *
264
     * "Update" and "delete" methods are passed the same items that
265
     * are given to their respective method.
266
     *
267
     * "Find" methods receive the ID searched for (if present), and
268
     * 'afterFind' additionally receives the results that were found.
269
     */
270

271
    /**
272
     * Whether to trigger the defined callbacks.
273
     *
274
     * @var bool
275
     */
276
    protected $allowCallbacks = true;
277

278
    /**
279
     * Used by $this->allowCallbacks() to override the
280
     * model's $allowCallbacks setting.
281
     *
282
     * @var bool
283
     */
284
    protected $tempAllowCallbacks;
285

286
    /**
287
     * Callbacks for "beforeInsert" event.
288
     *
289
     * @var list<string>
290
     */
291
    protected $beforeInsert = [];
292

293
    /**
294
     * Callbacks for "afterInsert" event.
295
     *
296
     * @var list<string>
297
     */
298
    protected $afterInsert = [];
299

300
    /**
301
     * Callbacks for "beforeUpdate" event.
302
     *
303
     * @var list<string>
304
     */
305
    protected $beforeUpdate = [];
306

307
    /**
308
     * Callbacks for "afterUpdate" event.
309
     *
310
     * @var list<string>
311
     */
312
    protected $afterUpdate = [];
313

314
    /**
315
     * Callbacks for "beforeInsertBatch" event.
316
     *
317
     * @var list<string>
318
     */
319
    protected $beforeInsertBatch = [];
320

321
    /**
322
     * Callbacks for "afterInsertBatch" event.
323
     *
324
     * @var list<string>
325
     */
326
    protected $afterInsertBatch = [];
327

328
    /**
329
     * Callbacks for "beforeUpdateBatch" event.
330
     *
331
     * @var list<string>
332
     */
333
    protected $beforeUpdateBatch = [];
334

335
    /**
336
     * Callbacks for "afterUpdateBatch" event.
337
     *
338
     * @var list<string>
339
     */
340
    protected $afterUpdateBatch = [];
341

342
    /**
343
     * Callbacks for "beforeFind" event.
344
     *
345
     * @var list<string>
346
     */
347
    protected $beforeFind = [];
348

349
    /**
350
     * Callbacks for "afterFind" event.
351
     *
352
     * @var list<string>
353
     */
354
    protected $afterFind = [];
355

356
    /**
357
     * Callbacks for "beforeDelete" event.
358
     *
359
     * @var list<string>
360
     */
361
    protected $beforeDelete = [];
362

363
    /**
364
     * Callbacks for "afterDelete" event.
365
     *
366
     * @var list<string>
367
     */
368
    protected $afterDelete = [];
369

370
    public function __construct(?ValidationInterface $validation = null)
371
    {
372
        $this->tempReturnType     = $this->returnType;
315✔
373
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
315✔
374
        $this->tempAllowCallbacks = $this->allowCallbacks;
315✔
375

376
        $this->validation = $validation;
315✔
377

378
        $this->initialize();
315✔
379
        $this->createDataConverter();
315✔
380
    }
381

382
    /**
383
     * Creates DataConverter instance.
384
     */
385
    protected function createDataConverter(): void
386
    {
387
        if ($this->useCasts()) {
315✔
388
            $this->converter = new DataConverter(
30✔
389
                $this->casts,
30✔
390
                $this->castHandlers,
30✔
391
                $this->db,
30✔
392
            );
30✔
393
        }
394
    }
395

396
    /**
397
     * Are casts used?
398
     */
399
    protected function useCasts(): bool
400
    {
401
        return $this->casts !== [];
315✔
402
    }
403

404
    /**
405
     * Initializes the instance with any additional steps.
406
     * Optionally implemented by child classes.
407
     *
408
     * @return void
409
     */
410
    protected function initialize()
411
    {
412
    }
314✔
413

414
    /**
415
     * Fetches the row(s) of database with a primary key
416
     * matching $id.
417
     * This method works only with dbCalls.
418
     *
419
     * @param bool                             $singleton Single or multiple results.
420
     * @param int|list<int|string>|string|null $id        One primary key or an array of primary keys.
421
     *
422
     * @return         list<object|row_array>|object|row_array|null                          The resulting row of data or null.
423
     * @phpstan-return ($singleton is true ? object|row_array|null : list<object|row_array>)
424
     */
425
    abstract protected function doFind(bool $singleton, $id = null);
426

427
    /**
428
     * Fetches the column of database.
429
     * This method works only with dbCalls.
430
     *
431
     * @return list<row_array>|null The resulting row of data or null if no data found.
432
     *
433
     * @throws DataException
434
     */
435
    abstract protected function doFindColumn(string $columnName);
436

437
    /**
438
     * Fetches all results, while optionally limiting them.
439
     * This method works only with dbCalls.
440
     *
441
     * @return list<object|row_array>
442
     */
443
    abstract protected function doFindAll(?int $limit = null, int $offset = 0);
444

445
    /**
446
     * Returns the first row of the result set.
447
     * This method works only with dbCalls.
448
     *
449
     * @return object|row_array|null
450
     */
451
    abstract protected function doFirst();
452

453
    /**
454
     * Inserts data into the current database.
455
     * This method works only with dbCalls.
456
     *
457
     * @param row_array $row
458
     *
459
     * @return bool
460
     */
461
    abstract protected function doInsert(array $row);
462

463
    /**
464
     * Compiles batch insert and runs the queries, validating each row prior.
465
     * This method works only with dbCalls.
466
     *
467
     * @param list<object|row_array>|null $set       An associative array of insert values
468
     * @param bool|null                   $escape    Whether to escape values
469
     * @param int                         $batchSize The size of the batch to run
470
     * @param bool                        $testing   `true` means only number of records is returned, `false` will execute the query
471
     *
472
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode
473
     */
474
    abstract protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false);
475

476
    /**
477
     * Updates a single record in the database.
478
     * This method works only with dbCalls.
479
     *
480
     * @param array<int|string, int|string>|int|string|null $id
481
     * @param row_array|null                                $row
482
     */
483
    abstract protected function doUpdate($id = null, $row = null): bool;
484

485
    /**
486
     * Compiles an update and runs the query.
487
     * This method works only with dbCalls.
488
     *
489
     * @param list<object|row_array>|null $set       An associative array of update values
490
     * @param string|null                 $index     The where key
491
     * @param int                         $batchSize The size of the batch to run
492
     * @param bool                        $returnSQL `true` means SQL is returned, `false` will execute the query
493
     *
494
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode
495
     *
496
     * @throws DatabaseException
497
     */
498
    abstract protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false);
499

500
    /**
501
     * Deletes a single record from the database where $id matches
502
     * the table's primary key.
503
     * This method works only with dbCalls.
504
     *
505
     * @param array<int|string, int|string>|int|string|null $id    The rows primary key(s)
506
     * @param bool                                          $purge Allows overriding the soft deletes setting
507
     *
508
     * @return bool|string Returns a SQL string if in test mode
509
     *
510
     * @throws DatabaseException
511
     */
512
    abstract protected function doDelete($id = null, bool $purge = false);
513

514
    /**
515
     * Permanently deletes all rows that have been marked as deleted
516
     * through soft deletes (value of column $deletedField is not null).
517
     * This method works only with dbCalls.
518
     *
519
     * @return bool|string Returns a SQL string if in test mode
520
     */
521
    abstract protected function doPurgeDeleted();
522

523
    /**
524
     * Works with the $this->find* methods to return only the rows that
525
     * have been deleted (value of column $deletedField is not null).
526
     * This method works only with dbCalls.
527
     *
528
     * @return void
529
     */
530
    abstract protected function doOnlyDeleted();
531

532
    /**
533
     * Compiles a replace and runs the query.
534
     * This method works only with dbCalls.
535
     *
536
     * @param row_array|null $row
537
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query
538
     *
539
     * @return BaseResult|false|Query|string
540
     */
541
    abstract protected function doReplace(?array $row = null, bool $returnSQL = false);
542

543
    /**
544
     * Grabs the last error(s) that occurred from the Database connection.
545
     * This method works only with dbCalls.
546
     *
547
     * @return array<string, string>
548
     */
549
    abstract protected function doErrors();
550

551
    /**
552
     * Public getter to return the id value for the data array or object.
553
     * For example with SQL this will return `$data->{$this->primaryKey}`.
554
     *
555
     * @param object|row_array $row
556
     *
557
     * @return int|string|null
558
     */
559
    abstract public function getIdValue($row);
560

561
    /**
562
     * Override countAllResults to account for soft deleted accounts.
563
     * This method works only with dbCalls.
564
     *
565
     * @param bool $reset When `false`, the `$tempUseSoftDeletes` will be
566
     *                    dependent on `$useSoftDeletes` value because we don't
567
     *                    want to add the same "where" condition for the second time
568
     * @param bool $test  `true` returns the number of all records, `false` will execute the query
569
     *
570
     * @return int|string Returns a SQL string if in test mode
571
     */
572
    abstract public function countAllResults(bool $reset = true, bool $test = false);
573

574
    /**
575
     * Loops over records in batches, allowing you to operate on them.
576
     * This method works only with dbCalls.
577
     *
578
     * @param Closure(array<string, string>|object): mixed $userFunc
579
     *
580
     * @return void
581
     *
582
     * @throws DataException
583
     */
584
    abstract public function chunk(int $size, Closure $userFunc);
585

586
    /**
587
     * Fetches the row of database.
588
     *
589
     * @param int|list<int|string>|string|null $id One primary key or an array of primary keys
590
     *
591
     * @return         list<object|row_array>|object|row_array|null
592
     * @phpstan-return ($id is int|string ? object|row_array|null :  list<object|row_array>)
593
     */
594
    public function find($id = null)
595
    {
596
        $singleton = is_numeric($id) || is_string($id);
58✔
597

598
        if ($this->tempAllowCallbacks) {
58✔
599
            // Call the before event and check for a return
600
            $eventData = $this->trigger('beforeFind', [
55✔
601
                'id'        => $id,
55✔
602
                'method'    => 'find',
55✔
603
                'singleton' => $singleton,
55✔
604
            ]);
55✔
605

606
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
55✔
607
                return $eventData['data'];
3✔
608
            }
609
        }
610

611
        $eventData = [
56✔
612
            'id'        => $id,
56✔
613
            'data'      => $this->doFind($singleton, $id),
56✔
614
            'method'    => 'find',
56✔
615
            'singleton' => $singleton,
56✔
616
        ];
56✔
617

618
        if ($this->tempAllowCallbacks) {
55✔
619
            $eventData = $this->trigger('afterFind', $eventData);
52✔
620
        }
621

622
        $this->tempReturnType     = $this->returnType;
55✔
623
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
55✔
624
        $this->tempAllowCallbacks = $this->allowCallbacks;
55✔
625

626
        return $eventData['data'];
55✔
627
    }
628

629
    /**
630
     * Fetches the column of database.
631
     *
632
     * @return list<bool|float|int|list<mixed>|object|string|null>|null The resulting row of data, or null if no data found
633
     *
634
     * @throws DataException
635
     */
636
    public function findColumn(string $columnName)
637
    {
638
        if (str_contains($columnName, ',')) {
3✔
639
            throw DataException::forFindColumnHaveMultipleColumns();
1✔
640
        }
641

642
        $resultSet = $this->doFindColumn($columnName);
2✔
643

644
        return $resultSet !== null ? array_column($resultSet, $columnName) : null;
2✔
645
    }
646

647
    /**
648
     * Fetches all results, while optionally limiting them.
649
     *
650
     * @return list<object|row_array>
651
     */
652
    public function findAll(?int $limit = null, int $offset = 0)
653
    {
654
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true;
22✔
655
        if ($limitZeroAsAll) {
22✔
656
            $limit ??= 0;
22✔
657
        }
658

659
        if ($this->tempAllowCallbacks) {
22✔
660
            // Call the before event and check for a return
661
            $eventData = $this->trigger('beforeFind', [
22✔
662
                'method'    => 'findAll',
22✔
663
                'limit'     => $limit,
22✔
664
                'offset'    => $offset,
22✔
665
                'singleton' => false,
22✔
666
            ]);
22✔
667

668
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
22✔
669
                return $eventData['data'];
1✔
670
            }
671
        }
672

673
        $eventData = [
22✔
674
            'data'      => $this->doFindAll($limit, $offset),
22✔
675
            'limit'     => $limit,
22✔
676
            'offset'    => $offset,
22✔
677
            'method'    => 'findAll',
22✔
678
            'singleton' => false,
22✔
679
        ];
22✔
680

681
        if ($this->tempAllowCallbacks) {
22✔
682
            $eventData = $this->trigger('afterFind', $eventData);
22✔
683
        }
684

685
        $this->tempReturnType     = $this->returnType;
22✔
686
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
22✔
687
        $this->tempAllowCallbacks = $this->allowCallbacks;
22✔
688

689
        return $eventData['data'];
22✔
690
    }
691

692
    /**
693
     * Returns the first row of the result set.
694
     *
695
     * @return object|row_array|null
696
     */
697
    public function first()
698
    {
699
        if ($this->tempAllowCallbacks) {
24✔
700
            // Call the before event and check for a return
701
            $eventData = $this->trigger('beforeFind', [
24✔
702
                'method'    => 'first',
24✔
703
                'singleton' => true,
24✔
704
            ]);
24✔
705

706
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
24✔
707
                return $eventData['data'];
1✔
708
            }
709
        }
710

711
        $eventData = [
24✔
712
            'data'      => $this->doFirst(),
24✔
713
            'method'    => 'first',
24✔
714
            'singleton' => true,
24✔
715
        ];
24✔
716

717
        if ($this->tempAllowCallbacks) {
24✔
718
            $eventData = $this->trigger('afterFind', $eventData);
24✔
719
        }
720

721
        $this->tempReturnType     = $this->returnType;
24✔
722
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
24✔
723
        $this->tempAllowCallbacks = $this->allowCallbacks;
24✔
724

725
        return $eventData['data'];
24✔
726
    }
727

728
    /**
729
     * A convenience method that will attempt to determine whether the
730
     * data should be inserted or updated.
731
     *
732
     * Will work with either an array or object.
733
     * When using with custom class objects,
734
     * you must ensure that the class will provide access to the class
735
     * variables, even if through a magic method.
736
     *
737
     * @param object|row_array $row
738
     *
739
     * @throws ReflectionException
740
     */
741
    public function save($row): bool
742
    {
743
        if ((array) $row === []) {
28✔
744
            return true;
1✔
745
        }
746

747
        if ($this->shouldUpdate($row)) {
27✔
748
            $response = $this->update($this->getIdValue($row), $row);
17✔
749
        } else {
750
            $response = $this->insert($row, false);
14✔
751

752
            if ($response !== false) {
13✔
753
                $response = true;
12✔
754
            }
755
        }
756

757
        return $response;
26✔
758
    }
759

760
    /**
761
     * This method is called on save to determine if entry have to be updated.
762
     * If this method returns false insert operation will be executed
763
     *
764
     * @param object|row_array $row
765
     */
766
    protected function shouldUpdate($row): bool
767
    {
768
        $id = $this->getIdValue($row);
27✔
769

770
        return $id !== null && $id !== '';
27✔
771
    }
772

773
    /**
774
     * Returns last insert ID or 0.
775
     *
776
     * @return int|string
777
     */
778
    public function getInsertID()
779
    {
780
        return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID;
11✔
781
    }
782

783
    /**
784
     * Inserts data into the database. If an object is provided,
785
     * it will attempt to convert it to an array.
786
     *
787
     * @param object|row_array|null $row
788
     * @param bool                  $returnID Whether insert ID should be returned or not
789
     *
790
     * @return         bool|int|string
791
     * @phpstan-return ($returnID is true ? false|int|string : bool)
792
     *
793
     * @throws ReflectionException
794
     */
795
    public function insert($row = null, bool $returnID = true)
796
    {
797
        $this->insertID = 0;
109✔
798

799
        // Set $cleanValidationRules to false temporary.
800
        $cleanValidationRules       = $this->cleanValidationRules;
109✔
801
        $this->cleanValidationRules = false;
109✔
802

803
        $row = $this->transformDataToArray($row, 'insert');
109✔
804

805
        // Validate data before saving.
806
        if (! $this->skipValidation && ! $this->validate($row)) {
107✔
807
            // Restore $cleanValidationRules
808
            $this->cleanValidationRules = $cleanValidationRules;
19✔
809

810
            return false;
19✔
811
        }
812

813
        // Restore $cleanValidationRules
814
        $this->cleanValidationRules = $cleanValidationRules;
90✔
815

816
        // Must be called first, so we don't
817
        // strip out created_at values.
818
        $row = $this->doProtectFieldsForInsert($row);
90✔
819

820
        // doProtectFields() can further remove elements from
821
        // $row, so we need to check for empty dataset again
822
        if (! $this->allowEmptyInserts && $row === []) {
89✔
823
            throw DataException::forEmptyDataset('insert');
2✔
824
        }
825

826
        // Set created_at and updated_at with same time
827
        $date = $this->setDate();
87✔
828
        $row  = $this->setCreatedField($row, $date);
87✔
829
        $row  = $this->setUpdatedField($row, $date);
87✔
830

831
        $eventData = ['data' => $row];
87✔
832

833
        if ($this->tempAllowCallbacks) {
87✔
834
            $eventData = $this->trigger('beforeInsert', $eventData);
87✔
835
        }
836

837
        $result = $this->doInsert($eventData['data']);
86✔
838

839
        $eventData = [
85✔
840
            'id'     => $this->insertID,
85✔
841
            'data'   => $eventData['data'],
85✔
842
            'result' => $result,
85✔
843
        ];
85✔
844

845
        if ($this->tempAllowCallbacks) {
85✔
846
            // Trigger afterInsert events with the inserted data and new ID
847
            $this->trigger('afterInsert', $eventData);
85✔
848
        }
849

850
        $this->tempAllowCallbacks = $this->allowCallbacks;
85✔
851

852
        // If insertion failed, get out of here
853
        if (! $result) {
85✔
854
            return $result;
2✔
855
        }
856

857
        // otherwise return the insertID, if requested.
858
        return $returnID ? $this->insertID : $result;
83✔
859
    }
860

861
    /**
862
     * Set datetime to created field.
863
     *
864
     * @param row_array  $row
865
     * @param int|string $date Timestamp or datetime string
866
     */
867
    protected function setCreatedField(array $row, $date): array
868
    {
869
        if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) {
99✔
870
            $row[$this->createdField] = $date;
39✔
871
        }
872

873
        return $row;
99✔
874
    }
875

876
    /**
877
     * Set datetime to updated field.
878
     *
879
     * @param row_array  $row
880
     * @param int|string $date Timestamp or datetime string
881
     */
882
    protected function setUpdatedField(array $row, $date): array
883
    {
884
        if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) {
114✔
885
            $row[$this->updatedField] = $date;
43✔
886
        }
887

888
        return $row;
114✔
889
    }
890

891
    /**
892
     * Compiles batch insert runs the queries, validating each row prior.
893
     *
894
     * @param list<object|row_array>|null $set       An associative array of insert values
895
     * @param bool|null                   $escape    Whether to escape values
896
     * @param int                         $batchSize The size of the batch to run
897
     * @param bool                        $testing   `true` means only number of records is returned, `false` will execute the query
898
     *
899
     * @return false|int|list<string> Number of rows inserted or `false` on failure
900
     *
901
     * @throws ReflectionException
902
     */
903
    public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false)
904
    {
905
        // Set $cleanValidationRules to false temporary.
906
        $cleanValidationRules       = $this->cleanValidationRules;
14✔
907
        $this->cleanValidationRules = false;
14✔
908

909
        if (is_array($set)) {
14✔
910
            foreach ($set as &$row) {
14✔
911
                $row = $this->transformDataToArray($row, 'insert');
14✔
912

913
                // Validate every row.
914
                if (! $this->skipValidation && ! $this->validate($row)) {
14✔
915
                    // Restore $cleanValidationRules
916
                    $this->cleanValidationRules = $cleanValidationRules;
3✔
917

918
                    return false;
3✔
919
                }
920

921
                // Must be called first so we don't
922
                // strip out created_at values.
923
                $row = $this->doProtectFieldsForInsert($row);
11✔
924

925
                // Set created_at and updated_at with same time
926
                $date = $this->setDate();
11✔
927
                $row  = $this->setCreatedField($row, $date);
11✔
928
                $row  = $this->setUpdatedField($row, $date);
11✔
929
            }
930
        }
931

932
        // Restore $cleanValidationRules
933
        $this->cleanValidationRules = $cleanValidationRules;
11✔
934

935
        $eventData = ['data' => $set];
11✔
936

937
        if ($this->tempAllowCallbacks) {
11✔
938
            $eventData = $this->trigger('beforeInsertBatch', $eventData);
11✔
939
        }
940

941
        $result = $this->doInsertBatch($eventData['data'], $escape, $batchSize, $testing);
11✔
942

943
        $eventData = [
11✔
944
            'data'   => $eventData['data'],
11✔
945
            'result' => $result,
11✔
946
        ];
11✔
947

948
        if ($this->tempAllowCallbacks) {
11✔
949
            // Trigger afterInsert events with the inserted data and new ID
950
            $this->trigger('afterInsertBatch', $eventData);
11✔
951
        }
952

953
        $this->tempAllowCallbacks = $this->allowCallbacks;
11✔
954

955
        return $result;
11✔
956
    }
957

958
    /**
959
     * Updates a single record in the database. If an object is provided,
960
     * it will attempt to convert it into an array.
961
     *
962
     * @param array<int|string, int|string>|int|string|null $id
963
     * @param object|row_array|null                         $row
964
     *
965
     * @throws ReflectionException
966
     */
967
    public function update($id = null, $row = null): bool
968
    {
969
        if (is_bool($id)) {
46✔
970
            throw new InvalidArgumentException('update(): argument #1 ($id) should not be boolean.');
1✔
971
        }
972

973
        if (is_numeric($id) || is_string($id)) {
45✔
974
            $id = [$id];
38✔
975
        }
976

977
        $row = $this->transformDataToArray($row, 'update');
45✔
978

979
        // Validate data before saving.
980
        if (! $this->skipValidation && ! $this->validate($row)) {
43✔
981
            return false;
4✔
982
        }
983

984
        // Must be called first, so we don't
985
        // strip out updated_at values.
986
        $row = $this->doProtectFields($row);
39✔
987

988
        // doProtectFields() can further remove elements from
989
        // $row, so we need to check for empty dataset again
990
        if ($row === []) {
39✔
991
            throw DataException::forEmptyDataset('update');
2✔
992
        }
993

994
        $row = $this->setUpdatedField($row, $this->setDate());
37✔
995

996
        $eventData = [
37✔
997
            'id'   => $id,
37✔
998
            'data' => $row,
37✔
999
        ];
37✔
1000

1001
        if ($this->tempAllowCallbacks) {
37✔
1002
            $eventData = $this->trigger('beforeUpdate', $eventData);
37✔
1003
        }
1004

1005
        $eventData = [
37✔
1006
            'id'     => $id,
37✔
1007
            'data'   => $eventData['data'],
37✔
1008
            'result' => $this->doUpdate($id, $eventData['data']),
37✔
1009
        ];
37✔
1010

1011
        if ($this->tempAllowCallbacks) {
36✔
1012
            $this->trigger('afterUpdate', $eventData);
36✔
1013
        }
1014

1015
        $this->tempAllowCallbacks = $this->allowCallbacks;
36✔
1016

1017
        return $eventData['result'];
36✔
1018
    }
1019

1020
    /**
1021
     * Compiles an update and runs the query.
1022
     *
1023
     * @param list<object|row_array>|null $set       An associative array of insert values
1024
     * @param string|null                 $index     The where key
1025
     * @param int                         $batchSize The size of the batch to run
1026
     * @param bool                        $returnSQL `true` means SQL is returned, `false` will execute the query
1027
     *
1028
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode
1029
     *
1030
     * @throws DatabaseException
1031
     * @throws ReflectionException
1032
     */
1033
    public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
1034
    {
1035
        if (is_array($set)) {
6✔
1036
            foreach ($set as &$row) {
6✔
1037
                $row = $this->transformDataToArray($row, 'update');
6✔
1038

1039
                // Validate data before saving.
1040
                if (! $this->skipValidation && ! $this->validate($row)) {
6✔
1041
                    return false;
1✔
1042
                }
1043

1044
                // Save updateIndex for later
1045
                $updateIndex = $row[$index] ?? null;
5✔
1046

1047
                if ($updateIndex === null) {
5✔
1048
                    throw new InvalidArgumentException(
1✔
1049
                        'The index ("' . $index . '") for updateBatch() is missing in the data: '
1✔
1050
                        . json_encode($row),
1✔
1051
                    );
1✔
1052
                }
1053

1054
                // Must be called first so we don't
1055
                // strip out updated_at values.
1056
                $row = $this->doProtectFields($row);
4✔
1057

1058
                // Restore updateIndex value in case it was wiped out
1059
                $row[$index] = $updateIndex;
4✔
1060

1061
                $row = $this->setUpdatedField($row, $this->setDate());
4✔
1062
            }
1063
        }
1064

1065
        $eventData = ['data' => $set];
4✔
1066

1067
        if ($this->tempAllowCallbacks) {
4✔
1068
            $eventData = $this->trigger('beforeUpdateBatch', $eventData);
4✔
1069
        }
1070

1071
        $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL);
4✔
1072

1073
        $eventData = [
4✔
1074
            'data'   => $eventData['data'],
4✔
1075
            'result' => $result,
4✔
1076
        ];
4✔
1077

1078
        if ($this->tempAllowCallbacks) {
4✔
1079
            // Trigger afterInsert events with the inserted data and new ID
1080
            $this->trigger('afterUpdateBatch', $eventData);
4✔
1081
        }
1082

1083
        $this->tempAllowCallbacks = $this->allowCallbacks;
4✔
1084

1085
        return $result;
4✔
1086
    }
1087

1088
    /**
1089
     * Deletes a single record from the database where $id matches.
1090
     *
1091
     * @param int|list<int|string>|string|null $id    The rows primary key(s)
1092
     * @param bool                             $purge Allows overriding the soft deletes setting
1093
     *
1094
     * @return bool|string Returns a SQL string if in test mode
1095
     *
1096
     * @throws DatabaseException
1097
     */
1098
    public function delete($id = null, bool $purge = false)
1099
    {
1100
        if (is_bool($id)) {
40✔
UNCOV
1101
            throw new InvalidArgumentException('delete(): argument #1 ($id) should not be boolean.');
×
1102
        }
1103

1104
        if (! in_array($id, [null, 0, '0'], true) && (is_numeric($id) || is_string($id))) {
40✔
1105
            $id = [$id];
20✔
1106
        }
1107

1108
        $eventData = [
40✔
1109
            'id'    => $id,
40✔
1110
            'purge' => $purge,
40✔
1111
        ];
40✔
1112

1113
        if ($this->tempAllowCallbacks) {
40✔
1114
            $this->trigger('beforeDelete', $eventData);
39✔
1115
        }
1116

1117
        $eventData = [
40✔
1118
            'id'     => $id,
40✔
1119
            'data'   => null,
40✔
1120
            'purge'  => $purge,
40✔
1121
            'result' => $this->doDelete($id, $purge),
40✔
1122
        ];
40✔
1123

1124
        if ($this->tempAllowCallbacks) {
27✔
1125
            $this->trigger('afterDelete', $eventData);
26✔
1126
        }
1127

1128
        $this->tempAllowCallbacks = $this->allowCallbacks;
27✔
1129

1130
        return $eventData['result'];
27✔
1131
    }
1132

1133
    /**
1134
     * Permanently deletes all rows that have been marked as deleted
1135
     * through soft deletes (value of column $deletedField is not null).
1136
     *
1137
     * @return bool|string Returns a SQL string if in test mode
1138
     */
1139
    public function purgeDeleted()
1140
    {
1141
        if (! $this->useSoftDeletes) {
2✔
1142
            return true;
1✔
1143
        }
1144

1145
        return $this->doPurgeDeleted();
1✔
1146
    }
1147

1148
    /**
1149
     * Sets $useSoftDeletes value so that we can temporarily override
1150
     * the soft deletes settings. Can be used for all find* methods.
1151
     *
1152
     * @return $this
1153
     */
1154
    public function withDeleted(bool $val = true)
1155
    {
1156
        $this->tempUseSoftDeletes = ! $val;
23✔
1157

1158
        return $this;
23✔
1159
    }
1160

1161
    /**
1162
     * Works with the $this->find* methods to return only the rows that
1163
     * have been deleted.
1164
     *
1165
     * @return $this
1166
     */
1167
    public function onlyDeleted()
1168
    {
1169
        $this->tempUseSoftDeletes = false;
1✔
1170
        $this->doOnlyDeleted();
1✔
1171

1172
        return $this;
1✔
1173
    }
1174

1175
    /**
1176
     * Compiles a replace and runs the query.
1177
     *
1178
     * @param row_array|null $row
1179
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query
1180
     *
1181
     * @return BaseResult|false|Query|string
1182
     */
1183
    public function replace(?array $row = null, bool $returnSQL = false)
1184
    {
1185
        // Validate data before saving.
1186
        if (($row !== null) && ! $this->skipValidation && ! $this->validate($row)) {
3✔
1187
            return false;
1✔
1188
        }
1189

1190
        $row = (array) $row;
2✔
1191
        $row = $this->setCreatedField($row, $this->setDate());
2✔
1192
        $row = $this->setUpdatedField($row, $this->setDate());
2✔
1193

1194
        return $this->doReplace($row, $returnSQL);
2✔
1195
    }
1196

1197
    /**
1198
     * Grabs the last error(s) that occurred.
1199
     *
1200
     * If data was validated, it will first check for errors there,
1201
     *  otherwise will try to grab the last error from the Database connection.
1202
     *
1203
     * The return array should be in the following format:
1204
     *  `['source' => 'message']`.
1205
     *
1206
     * @param bool $forceDB Always grab the db error, not validation
1207
     *
1208
     * @return array<string, string>
1209
     */
1210
    public function errors(bool $forceDB = false)
1211
    {
1212
        if ($this->validation === null) {
24✔
UNCOV
1213
            return $this->doErrors();
×
1214
        }
1215

1216
        // Do we have validation errors?
1217
        if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors()) !== []) {
24✔
1218
            return $errors;
22✔
1219
        }
1220

1221
        return $this->doErrors();
2✔
1222
    }
1223

1224
    /**
1225
     * Works with Pager to get the size and offset parameters.
1226
     * Expects a GET variable (?page=2) that specifies the page of results
1227
     * to display.
1228
     *
1229
     * @param int|null $perPage Items per page
1230
     * @param string   $group   Will be used by the pagination library to identify a unique pagination set
1231
     * @param int|null $page    Optional page number (useful when the page number is provided in different way)
1232
     * @param int      $segment Optional URI segment number (if page number is provided by URI segment)
1233
     *
1234
     * @return list<object|row_array>
1235
     */
1236
    public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0)
1237
    {
1238
        // Since multiple models may use the Pager, the Pager must be shared.
1239
        $pager = service('pager');
8✔
1240

1241
        if ($segment !== 0) {
8✔
UNCOV
1242
            $pager->setSegment($segment, $group);
×
1243
        }
1244

1245
        $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
8✔
1246
        // Store it in the Pager library, so it can be paginated in the views.
1247
        $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
8✔
1248
        $perPage     = $this->pager->getPerPage($group);
8✔
1249
        $offset      = ($pager->getCurrentPage($group) - 1) * $perPage;
8✔
1250

1251
        return $this->findAll($perPage, $offset);
8✔
1252
    }
1253

1254
    /**
1255
     * It could be used when you have to change default or override current allowed fields.
1256
     *
1257
     * @param list<string> $allowedFields Array with names of fields
1258
     *
1259
     * @return $this
1260
     */
1261
    public function setAllowedFields(array $allowedFields)
1262
    {
1263
        $this->allowedFields = $allowedFields;
10✔
1264

1265
        return $this;
10✔
1266
    }
1267

1268
    /**
1269
     * Sets whether or not we should whitelist data set during
1270
     * updates or inserts against $this->availableFields.
1271
     *
1272
     * @return $this
1273
     */
1274
    public function protect(bool $protect = true)
1275
    {
1276
        $this->protectFields = $protect;
12✔
1277

1278
        return $this;
12✔
1279
    }
1280

1281
    /**
1282
     * Ensures that only the fields that are allowed to be updated are
1283
     * in the data array.
1284
     *
1285
     * @used-by update() to protect against mass assignment vulnerabilities.
1286
     * @used-by updateBatch() to protect against mass assignment vulnerabilities.
1287
     *
1288
     * @param row_array $row
1289
     *
1290
     * @throws DataException
1291
     */
1292
    protected function doProtectFields(array $row): array
1293
    {
1294
        if (! $this->protectFields) {
43✔
1295
            return $row;
2✔
1296
        }
1297

1298
        if ($this->allowedFields === []) {
41✔
UNCOV
1299
            throw DataException::forInvalidAllowedFields(static::class);
×
1300
        }
1301

1302
        foreach (array_keys($row) as $key) {
41✔
1303
            if (! in_array($key, $this->allowedFields, true)) {
41✔
1304
                unset($row[$key]);
23✔
1305
            }
1306
        }
1307

1308
        return $row;
41✔
1309
    }
1310

1311
    /**
1312
     * Ensures that only the fields that are allowed to be inserted are in
1313
     * the data array.
1314
     *
1315
     * @used-by insert() to protect against mass assignment vulnerabilities.
1316
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1317
     *
1318
     * @param row_array $row
1319
     *
1320
     * @throws DataException
1321
     */
1322
    protected function doProtectFieldsForInsert(array $row): array
1323
    {
UNCOV
1324
        return $this->doProtectFields($row);
×
1325
    }
1326

1327
    /**
1328
     * Sets the timestamp or current timestamp if null value is passed.
1329
     *
1330
     * @param int|null $userDate An optional PHP timestamp to be converted
1331
     *
1332
     * @return int|string
1333
     *
1334
     * @throws ModelException
1335
     */
1336
    protected function setDate(?int $userDate = null)
1337
    {
1338
        $currentDate = $userDate ?? Time::now()->getTimestamp();
133✔
1339

1340
        return $this->intToDate($currentDate);
133✔
1341
    }
1342

1343
    /**
1344
     * A utility function to allow child models to use the type of
1345
     * date/time format that they prefer. This is primarily used for
1346
     * setting created_at, updated_at and deleted_at values, but can be
1347
     * used by inheriting classes.
1348
     *
1349
     * The available time formats are:
1350
     *  - 'int'      - Stores the date as an integer timestamp
1351
     *  - 'datetime' - Stores the data in the SQL datetime format
1352
     *  - 'date'     - Stores the date (only) in the SQL date format.
1353
     *
1354
     * @return int|string
1355
     *
1356
     * @throws ModelException
1357
     */
1358
    protected function intToDate(int $value)
1359
    {
1360
        return match ($this->dateFormat) {
133✔
1361
            'int'      => $value,
36✔
1362
            'datetime' => date($this->db->dateFormat['datetime'], $value),
95✔
1363
            'date'     => date($this->db->dateFormat['date'], $value),
1✔
1364
            default    => throw ModelException::forNoDateFormat(static::class),
133✔
1365
        };
133✔
1366
    }
1367

1368
    /**
1369
     * Converts Time value to string using $this->dateFormat.
1370
     *
1371
     * The available time formats are:
1372
     *  - 'int'      - Stores the date as an integer timestamp
1373
     *  - 'datetime' - Stores the data in the SQL datetime format
1374
     *  - 'date'     - Stores the date (only) in the SQL date format.
1375
     *
1376
     * @return int|string
1377
     */
1378
    protected function timeToDate(Time $value)
1379
    {
1380
        return match ($this->dateFormat) {
5✔
1381
            'datetime' => $value->format($this->db->dateFormat['datetime']),
3✔
1382
            'date'     => $value->format($this->db->dateFormat['date']),
1✔
1383
            'int'      => $value->getTimestamp(),
1✔
1384
            default    => (string) $value,
5✔
1385
        };
5✔
1386
    }
1387

1388
    /**
1389
     * Set the value of the $skipValidation flag.
1390
     *
1391
     * @return $this
1392
     */
1393
    public function skipValidation(bool $skip = true)
1394
    {
1395
        $this->skipValidation = $skip;
2✔
1396

1397
        return $this;
2✔
1398
    }
1399

1400
    /**
1401
     * Allows to set (and reset) validation messages.
1402
     * It could be used when you have to change default or override current validate messages.
1403
     *
1404
     * @param array<string, array<string, string>> $validationMessages
1405
     *
1406
     * @return $this
1407
     */
1408
    public function setValidationMessages(array $validationMessages)
1409
    {
UNCOV
1410
        $this->validationMessages = $validationMessages;
×
1411

UNCOV
1412
        return $this;
×
1413
    }
1414

1415
    /**
1416
     * Allows to set field wise validation message.
1417
     * It could be used when you have to change default or override current validate messages.
1418
     *
1419
     * @param array<string, string> $fieldMessages
1420
     *
1421
     * @return $this
1422
     */
1423
    public function setValidationMessage(string $field, array $fieldMessages)
1424
    {
1425
        $this->validationMessages[$field] = $fieldMessages;
2✔
1426

1427
        return $this;
2✔
1428
    }
1429

1430
    /**
1431
     * Allows to set (and reset) validation rules.
1432
     * It could be used when you have to change default or override current validate rules.
1433
     *
1434
     * @param array<string, array<string, array<string, string>|string>|string> $validationRules
1435
     *
1436
     * @return $this
1437
     */
1438
    public function setValidationRules(array $validationRules)
1439
    {
1440
        $this->validationRules = $validationRules;
2✔
1441

1442
        return $this;
2✔
1443
    }
1444

1445
    /**
1446
     * Allows to set field wise validation rules.
1447
     * It could be used when you have to change default or override current validate rules.
1448
     *
1449
     * @param array<string, array<string, string>|string>|string $fieldRules
1450
     *
1451
     * @return $this
1452
     */
1453
    public function setValidationRule(string $field, $fieldRules)
1454
    {
1455
        $rules = $this->validationRules;
2✔
1456

1457
        // ValidationRules can be either a string, which is the group name,
1458
        // or an array of rules.
1459
        if (is_string($rules)) {
2✔
1460
            $this->ensureValidation();
1✔
1461

1462
            [$rules, $customErrors] = $this->validation->loadRuleGroup($rules);
1✔
1463

1464
            $this->validationRules = $rules;
1✔
1465
            $this->validationMessages += $customErrors;
1✔
1466
        }
1467

1468
        $this->validationRules[$field] = $fieldRules;
2✔
1469

1470
        return $this;
2✔
1471
    }
1472

1473
    /**
1474
     * Should validation rules be removed before saving?
1475
     * Most handy when doing updates.
1476
     *
1477
     * @return $this
1478
     */
1479
    public function cleanRules(bool $choice = false)
1480
    {
1481
        $this->cleanValidationRules = $choice;
2✔
1482

1483
        return $this;
2✔
1484
    }
1485

1486
    /**
1487
     * Validate the row data against the validation rules (or the validation group)
1488
     * specified in the class property, $validationRules.
1489
     *
1490
     * @param object|row_array $row
1491
     */
1492
    public function validate($row): bool
1493
    {
1494
        if ($this->skipValidation) {
151✔
UNCOV
1495
            return true;
×
1496
        }
1497

1498
        $rules = $this->getValidationRules();
151✔
1499

1500
        if ($rules === []) {
151✔
1501
            return true;
105✔
1502
        }
1503

1504
        // Validation requires array, so cast away.
1505
        if (is_object($row)) {
46✔
1506
            $row = (array) $row;
2✔
1507
        }
1508

1509
        if ($row === []) {
46✔
UNCOV
1510
            return true;
×
1511
        }
1512

1513
        $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules;
46✔
1514

1515
        // If no data existed that needs validation
1516
        // our job is done here.
1517
        if ($rules === []) {
46✔
1518
            return true;
2✔
1519
        }
1520

1521
        $this->ensureValidation();
44✔
1522

1523
        $this->validation->reset()->setRules($rules, $this->validationMessages);
44✔
1524

1525
        return $this->validation->run($row, null, $this->DBGroup);
44✔
1526
    }
1527

1528
    /**
1529
     * Returns the model's defined validation rules so that they
1530
     * can be used elsewhere, if needed.
1531
     *
1532
     * @param array{only?: list<string>, except?: list<string>} $options Filter the list of rules
1533
     *
1534
     * @return array<string, array<string, array<string, string>|string>|string>
1535
     */
1536
    public function getValidationRules(array $options = []): array
1537
    {
1538
        $rules = $this->validationRules;
153✔
1539

1540
        // ValidationRules can be either a string, which is the group name,
1541
        // or an array of rules.
1542
        if (is_string($rules)) {
153✔
1543
            $this->ensureValidation();
13✔
1544

1545
            [$rules, $customErrors] = $this->validation->loadRuleGroup($rules);
13✔
1546

1547
            $this->validationMessages += $customErrors;
13✔
1548
        }
1549

1550
        if (isset($options['except'])) {
153✔
UNCOV
1551
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1552
        } elseif (isset($options['only'])) {
153✔
UNCOV
1553
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1554
        }
1555

1556
        return $rules;
153✔
1557
    }
1558

1559
    protected function ensureValidation(): void
1560
    {
1561
        if ($this->validation === null) {
45✔
1562
            $this->validation = service('validation', null, false);
29✔
1563
        }
1564
    }
1565

1566
    /**
1567
     * Returns the model's validation messages, so they
1568
     * can be used elsewhere, if needed.
1569
     *
1570
     * @return array<string, array<string, string>>
1571
     */
1572
    public function getValidationMessages(): array
1573
    {
1574
        return $this->validationMessages;
2✔
1575
    }
1576

1577
    /**
1578
     * Removes any rules that apply to fields that have not been set
1579
     * currently so that rules don't block updating when only updating
1580
     * a partial row.
1581
     *
1582
     * @param array<string, array<string, array<string, string>|string>|string> $rules
1583
     * @param row_array                                                         $row
1584
     *
1585
     * @return array<string, array<string, array<string, string>|string>|string>
1586
     */
1587
    protected function cleanValidationRules(array $rules, array $row): array
1588
    {
1589
        if ($row === []) {
21✔
1590
            return [];
2✔
1591
        }
1592

1593
        foreach (array_keys($rules) as $field) {
19✔
1594
            if (! array_key_exists($field, $row)) {
19✔
1595
                unset($rules[$field]);
8✔
1596
            }
1597
        }
1598

1599
        return $rules;
19✔
1600
    }
1601

1602
    /**
1603
     * Sets $tempAllowCallbacks value so that we can temporarily override
1604
     * the setting. Resets after the next method that uses triggers.
1605
     *
1606
     * @return $this
1607
     */
1608
    public function allowCallbacks(bool $val = true)
1609
    {
1610
        $this->tempAllowCallbacks = $val;
3✔
1611

1612
        return $this;
3✔
1613
    }
1614

1615
    /**
1616
     * A simple event trigger for Model Events that allows additional
1617
     * data manipulation within the model. Specifically intended for
1618
     * usage by child models this can be used to format data,
1619
     * save/load related classes, etc.
1620
     *
1621
     * It is the responsibility of the callback methods to return
1622
     * the data itself.
1623
     *
1624
     * Each $eventData array MUST have a 'data' key with the relevant
1625
     * data for callback methods (like an array of key/value pairs to insert
1626
     * or update, an array of results, etc.)
1627
     *
1628
     * If callbacks are not allowed then returns $eventData immediately.
1629
     *
1630
     * @template TEventData of array<string, mixed>
1631
     *
1632
     * @param string     $event     Valid property of the model event: $this->before*, $this->after*, etc
1633
     * @param TEventData $eventData
1634
     *
1635
     * @return TEventData
1636
     *
1637
     * @throws DataException
1638
     */
1639
    protected function trigger(string $event, array $eventData)
1640
    {
1641
        // Ensure it's a valid event
1642
        if (! isset($this->{$event}) || $this->{$event} === []) {
193✔
1643
            return $eventData;
178✔
1644
        }
1645

1646
        foreach ($this->{$event} as $callback) {
15✔
1647
            if (! method_exists($this, $callback)) {
15✔
1648
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1649
            }
1650

1651
            $eventData = $this->{$callback}($eventData);
14✔
1652
        }
1653

1654
        return $eventData;
14✔
1655
    }
1656

1657
    /**
1658
     * Sets the return type of the results to be as an associative array.
1659
     *
1660
     * @return $this
1661
     */
1662
    public function asArray()
1663
    {
1664
        $this->tempReturnType = 'array';
31✔
1665

1666
        return $this;
31✔
1667
    }
1668

1669
    /**
1670
     * Sets the return type to be of the specified type of object.
1671
     * Defaults to a simple object, but can be any class that has
1672
     * class vars with the same name as the collection columns,
1673
     * or at least allows them to be created.
1674
     *
1675
     * @param 'object'|class-string $class
1676
     *
1677
     * @return $this
1678
     */
1679
    public function asObject(string $class = 'object')
1680
    {
1681
        $this->tempReturnType = $class;
18✔
1682

1683
        return $this;
18✔
1684
    }
1685

1686
    /**
1687
     * Takes a class and returns an array of its public and protected
1688
     * properties as an array suitable for use in creates and updates.
1689
     * This method uses `$this->objectToRawArray()` internally and does conversion
1690
     * to string on all Time instances.
1691
     *
1692
     * @param object $object
1693
     * @param bool   $onlyChanged Returns only the changed properties
1694
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well
1695
     *
1696
     * @return array<string, mixed>
1697
     *
1698
     * @throws ReflectionException
1699
     */
1700
    protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
1701
    {
1702
        $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
24✔
1703

1704
        // Convert any Time instances to appropriate $dateFormat
1705
        return $this->timeToString($properties);
24✔
1706
    }
1707

1708
    /**
1709
     * Convert any Time instances to appropriate $dateFormat.
1710
     *
1711
     * @param array<string, mixed> $properties
1712
     *
1713
     * @return array<string, mixed>
1714
     */
1715
    protected function timeToString(array $properties): array
1716
    {
1717
        if ($properties === []) {
142✔
1718
            return [];
1✔
1719
        }
1720

1721
        return array_map(function ($value) {
141✔
1722
            if ($value instanceof Time) {
141✔
1723
                return $this->timeToDate($value);
5✔
1724
            }
1725

1726
            return $value;
141✔
1727
        }, $properties);
141✔
1728
    }
1729

1730
    /**
1731
     * Takes a class and returns an array of its public and protected
1732
     * properties as an array with raw values.
1733
     *
1734
     * @param object $object
1735
     * @param bool   $onlyChanged Returns only the changed properties
1736
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well
1737
     *
1738
     * @return array<string, mixed> Array with raw values
1739
     *
1740
     * @throws ReflectionException
1741
     */
1742
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
1743
    {
1744
        // Entity::toRawArray() returns array
1745
        if (method_exists($object, 'toRawArray')) {
24✔
1746
            $properties = $object->toRawArray($onlyChanged, $recursive);
22✔
1747
        } else {
1748
            $mirror = new ReflectionClass($object);
2✔
1749
            $props  = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
2✔
1750

1751
            $properties = [];
2✔
1752

1753
            // Loop over each property,
1754
            // saving the name/value in a new array we can return
1755
            foreach ($props as $prop) {
2✔
1756
                $properties[$prop->getName()] = $prop->getValue($object);
2✔
1757
            }
1758
        }
1759

1760
        return $properties;
24✔
1761
    }
1762

1763
    /**
1764
     * Transform data to array.
1765
     *
1766
     * @param object|row_array|null $row
1767
     *
1768
     * @throws DataException
1769
     * @throws InvalidArgumentException
1770
     * @throws ReflectionException
1771
     *
1772
     * @used-by insert()
1773
     * @used-by insertBatch()
1774
     * @used-by update()
1775
     * @used-by updateBatch()
1776
     */
1777
    protected function transformDataToArray($row, string $type): array
1778
    {
1779
        if (! in_array($type, ['insert', 'update'], true)) {
146✔
1780
            throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
1✔
1781
        }
1782

1783
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
145✔
1784
            throw DataException::forEmptyDataset($type);
6✔
1785
        }
1786

1787
        // If it validates with entire rules, all fields are needed.
1788
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
142✔
1789
            $onlyChanged = false;
123✔
1790
        } else {
1791
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
48✔
1792
        }
1793

1794
        if ($this->useCasts()) {
142✔
1795
            if (is_array($row)) {
27✔
1796
                $row = $this->converter->toDataSource($row);
27✔
1797
            } elseif ($row instanceof stdClass) {
7✔
1798
                $row = (array) $row;
3✔
1799
                $row = $this->converter->toDataSource($row);
3✔
1800
            } elseif ($row instanceof Entity) {
4✔
1801
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1802
            } elseif (is_object($row)) {
2✔
1803
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1804
            }
1805
        }
1806
        // If $row is using a custom class with public or protected
1807
        // properties representing the collection elements, we need to grab
1808
        // them as an array.
1809
        elseif (is_object($row) && ! $row instanceof stdClass) {
115✔
1810
            $row = $this->objectToArray($row, $onlyChanged, true);
23✔
1811
        }
1812

1813
        // If it's still a stdClass, go ahead and convert to
1814
        // an array so doProtectFields and other model methods
1815
        // don't have to do special checks.
1816
        if (is_object($row)) {
142✔
1817
            $row = (array) $row;
13✔
1818
        }
1819

1820
        // If it's still empty here, means $row is no change or is empty object
1821
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
142✔
UNCOV
1822
            throw DataException::forEmptyDataset($type);
×
1823
        }
1824

1825
        // Convert any Time instances to appropriate $dateFormat
1826
        return $this->timeToString($row);
142✔
1827
    }
1828

1829
    /**
1830
     * Provides the db connection and model's properties.
1831
     *
1832
     * @return array<int|string, mixed>|bool|float|int|object|string|null
1833
     */
1834
    public function __get(string $name)
1835
    {
1836
        if (property_exists($this, $name)) {
50✔
1837
            return $this->{$name};
50✔
1838
        }
1839

1840
        return $this->db->{$name} ?? null;
1✔
1841
    }
1842

1843
    /**
1844
     * Checks for the existence of properties across this model, and db connection.
1845
     */
1846
    public function __isset(string $name): bool
1847
    {
1848
        if (property_exists($this, $name)) {
50✔
1849
            return true;
50✔
1850
        }
1851

1852
        return isset($this->db->{$name});
1✔
1853
    }
1854

1855
    /**
1856
     * Provides direct access to method in the database connection.
1857
     *
1858
     * @param array<int|string, mixed> $params
1859
     *
1860
     * @return $this|array<int|string, mixed>|bool|float|int|object|string|null
1861
     */
1862
    public function __call(string $name, array $params)
1863
    {
UNCOV
1864
        if (method_exists($this->db, $name)) {
×
UNCOV
1865
            return $this->db->{$name}(...$params);
×
1866
        }
1867

UNCOV
1868
        return null;
×
1869
    }
1870

1871
    /**
1872
     * Sets $allowEmptyInserts.
1873
     */
1874
    public function allowEmptyInserts(bool $value = true): self
1875
    {
1876
        $this->allowEmptyInserts = $value;
1✔
1877

1878
        return $this;
1✔
1879
    }
1880

1881
    /**
1882
     * Converts database data array to return type value.
1883
     *
1884
     * @param array<string, mixed>          $row        Raw data from database
1885
     * @param 'array'|'object'|class-string $returnType
1886
     */
1887
    protected function convertToReturnType(array $row, string $returnType): array|object
1888
    {
1889
        if ($returnType === 'array') {
25✔
1890
            return $this->converter->fromDataSource($row);
10✔
1891
        }
1892

1893
        if ($returnType === 'object') {
17✔
1894
            return (object) $this->converter->fromDataSource($row);
5✔
1895
        }
1896

1897
        return $this->converter->reconstruct($returnType, $row);
12✔
1898
    }
1899
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc