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

codeigniter4 / CodeIgniter4 / 17404765958

02 Sep 2025 01:17PM UTC coverage: 84.297% (-0.02%) from 84.32%
17404765958

Pull #9698

github

web-flow
Merge 2a5b1e4f4 into 63e9ebd6f
Pull Request #9698: fix: Casting in insertBatch and updateBatch methods.

10 of 18 new or added lines in 1 file covered. (55.56%)

25 existing lines in 1 file now uncovered.

20867 of 24754 relevant lines covered (84.3%)

194.31 hits per line

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

94.1
/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
     * Populated after calling $this->paginate()
64
     *
65
     * @var Pager
66
     */
67
    public $pager;
68

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

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

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

91
    /**
92
     * The format that the results should be returned as.
93
     * Will be overridden if the as* methods are used.
94
     *
95
     * @var string
96
     */
97
    protected $returnType = 'array';
98

99
    /**
100
     * Used by asArray() and asObject() to provide
101
     * temporary overrides of model default.
102
     *
103
     * @var 'array'|'object'|class-string
104
     */
105
    protected $tempReturnType;
106

107
    /**
108
     * Array of column names and the type of value to cast.
109
     *
110
     * @var array<string, string> [column => type]
111
     */
112
    protected array $casts = [];
113

114
    /**
115
     * Custom convert handlers.
116
     *
117
     * @var array<string, class-string> [type => classname]
118
     */
119
    protected array $castHandlers = [];
120

121
    protected ?DataConverter $converter = null;
122

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

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

144
    /**
145
     * If true, will set created_at, and updated_at
146
     * values during insert and update routines.
147
     *
148
     * @var bool
149
     */
150
    protected $useTimestamps = false;
151

152
    /**
153
     * The type of column that created_at and updated_at
154
     * are expected to.
155
     *
156
     * Allowed: 'datetime', 'date', 'int'
157
     *
158
     * @var string
159
     */
160
    protected $dateFormat = 'datetime';
161

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

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

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

185
    /**
186
     * Used by withDeleted to override the
187
     * model's softDelete setting.
188
     *
189
     * @var bool
190
     */
191
    protected $tempUseSoftDeletes;
192

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

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

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

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

222
    /**
223
     * Contains any custom error messages to be
224
     * used during data validation.
225
     *
226
     * @var array<string, array<string, string>>
227
     */
228
    protected $validationMessages = [];
229

230
    /**
231
     * Skip the model's validation. Used in conjunction with skipValidation()
232
     * to skip data validation for any future calls.
233
     *
234
     * @var bool
235
     */
236
    protected $skipValidation = false;
237

238
    /**
239
     * Whether rules should be removed that do not exist
240
     * in the passed data. Used in updates.
241
     *
242
     * @var bool
243
     */
244
    protected $cleanValidationRules = true;
245

246
    /**
247
     * Our validator instance.
248
     *
249
     * @var ValidationInterface|null
250
     */
251
    protected $validation;
252

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

266
    /**
267
     * Whether to trigger the defined callbacks
268
     *
269
     * @var bool
270
     */
271
    protected $allowCallbacks = true;
272

273
    /**
274
     * Used by allowCallbacks() to override the
275
     * model's allowCallbacks setting.
276
     *
277
     * @var bool
278
     */
279
    protected $tempAllowCallbacks;
280

281
    /**
282
     * Callbacks for beforeInsert
283
     *
284
     * @var list<string>
285
     */
286
    protected $beforeInsert = [];
287

288
    /**
289
     * Callbacks for afterInsert
290
     *
291
     * @var list<string>
292
     */
293
    protected $afterInsert = [];
294

295
    /**
296
     * Callbacks for beforeUpdate
297
     *
298
     * @var list<string>
299
     */
300
    protected $beforeUpdate = [];
301

302
    /**
303
     * Callbacks for afterUpdate
304
     *
305
     * @var list<string>
306
     */
307
    protected $afterUpdate = [];
308

309
    /**
310
     * Callbacks for beforeInsertBatch
311
     *
312
     * @var list<string>
313
     */
314
    protected $beforeInsertBatch = [];
315

316
    /**
317
     * Callbacks for afterInsertBatch
318
     *
319
     * @var list<string>
320
     */
321
    protected $afterInsertBatch = [];
322

323
    /**
324
     * Callbacks for beforeUpdateBatch
325
     *
326
     * @var list<string>
327
     */
328
    protected $beforeUpdateBatch = [];
329

330
    /**
331
     * Callbacks for afterUpdateBatch
332
     *
333
     * @var list<string>
334
     */
335
    protected $afterUpdateBatch = [];
336

337
    /**
338
     * Callbacks for beforeFind
339
     *
340
     * @var list<string>
341
     */
342
    protected $beforeFind = [];
343

344
    /**
345
     * Callbacks for afterFind
346
     *
347
     * @var list<string>
348
     */
349
    protected $afterFind = [];
350

351
    /**
352
     * Callbacks for beforeDelete
353
     *
354
     * @var list<string>
355
     */
356
    protected $beforeDelete = [];
357

358
    /**
359
     * Callbacks for afterDelete
360
     *
361
     * @var list<string>
362
     */
363
    protected $afterDelete = [];
364

365
    public function __construct(?ValidationInterface $validation = null)
366
    {
367
        $this->tempReturnType     = $this->returnType;
315✔
368
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
315✔
369
        $this->tempAllowCallbacks = $this->allowCallbacks;
315✔
370

371
        $this->validation = $validation;
315✔
372

373
        $this->initialize();
315✔
374
        $this->createDataConverter();
315✔
375
    }
376

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

391
    /**
392
     * Are casts used?
393
     */
394
    protected function useCasts(): bool
395
    {
396
        return $this->casts !== [];
315✔
397
    }
398

399
    /**
400
     * Initializes the instance with any additional steps.
401
     * Optionally implemented by child classes.
402
     *
403
     * @return void
404
     */
405
    protected function initialize()
406
    {
407
    }
314✔
408

409
    /**
410
     * Fetches the row of database.
411
     * This method works only with dbCalls.
412
     *
413
     * @param bool                  $singleton Single or multiple results
414
     * @param array|int|string|null $id        One primary key or an array of primary keys
415
     *
416
     * @return array|object|null The resulting row of data, or null.
417
     */
418
    abstract protected function doFind(bool $singleton, $id = null);
419

420
    /**
421
     * Fetches the column of database.
422
     * This method works only with dbCalls.
423
     *
424
     * @param string $columnName Column Name
425
     *
426
     * @return array|null The resulting row of data, or null if no data found.
427
     *
428
     * @throws DataException
429
     */
430
    abstract protected function doFindColumn(string $columnName);
431

432
    /**
433
     * Fetches all results, while optionally limiting them.
434
     * This method works only with dbCalls.
435
     *
436
     * @param int|null $limit  Limit
437
     * @param int      $offset Offset
438
     *
439
     * @return array
440
     */
441
    abstract protected function doFindAll(?int $limit = null, int $offset = 0);
442

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

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

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

475
    /**
476
     * Updates a single record in the database.
477
     * This method works only with dbCalls.
478
     *
479
     * @param         array|int|string|null $id  ID
480
     * @param         array|null            $row Row data
481
     * @phpstan-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 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 testMode
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
     * This method works only with dbCalls.
503
     *
504
     * @param array|int|string|null $id    The rows primary key(s)
505
     * @param bool                  $purge Allows overriding the soft deletes setting.
506
     *
507
     * @return bool|string
508
     *
509
     * @throws DatabaseException
510
     */
511
    abstract protected function doDelete($id = null, bool $purge = false);
512

513
    /**
514
     * Permanently deletes all rows that have been marked as deleted.
515
     * through soft deletes (deleted = 1).
516
     * This method works only with dbCalls.
517
     *
518
     * @return bool|string Returns a string if in test mode.
519
     */
520
    abstract protected function doPurgeDeleted();
521

522
    /**
523
     * Works with the find* methods to return only the rows that
524
     * have been deleted.
525
     * This method works only with dbCalls.
526
     *
527
     * @return void
528
     */
529
    abstract protected function doOnlyDeleted();
530

531
    /**
532
     * Compiles a replace and runs the query.
533
     * This method works only with dbCalls.
534
     *
535
     * @param row_array|null $row       Row data
536
     * @param bool           $returnSQL Set to true to return Query String
537
     *
538
     * @return BaseResult|false|Query|string
539
     */
540
    abstract protected function doReplace(?array $row = null, bool $returnSQL = false);
541

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

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

560
    /**
561
     * Override countAllResults to account for soft deleted accounts.
562
     * This method works only with dbCalls.
563
     *
564
     * @param bool $reset Reset
565
     * @param bool $test  Test
566
     *
567
     * @return int|string
568
     */
569
    abstract public function countAllResults(bool $reset = true, bool $test = false);
570

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

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

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

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

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

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

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

624
        return $eventData['data'];
55✔
625
    }
626

627
    /**
628
     * Fetches the column of database.
629
     *
630
     * @param string $columnName Column Name
631
     *
632
     * @return array|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
     * @param int $limit  Limit
651
     * @param int $offset Offset
652
     *
653
     * @return array
654
     */
655
    public function findAll(?int $limit = null, int $offset = 0)
656
    {
657
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true;
22✔
658
        if ($limitZeroAsAll) {
22✔
659
            $limit ??= 0;
22✔
660
        }
661

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

671
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
22✔
672
                return $eventData['data'];
1✔
673
            }
674
        }
675

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

684
        if ($this->tempAllowCallbacks) {
22✔
685
            $eventData = $this->trigger('afterFind', $eventData);
22✔
686
        }
687

688
        $this->tempReturnType     = $this->returnType;
22✔
689
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
22✔
690
        $this->tempAllowCallbacks = $this->allowCallbacks;
22✔
691

692
        return $eventData['data'];
22✔
693
    }
694

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

709
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
24✔
710
                return $eventData['data'];
1✔
711
            }
712
        }
713

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

720
        if ($this->tempAllowCallbacks) {
24✔
721
            $eventData = $this->trigger('afterFind', $eventData);
24✔
722
        }
723

724
        $this->tempReturnType     = $this->returnType;
24✔
725
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
24✔
726
        $this->tempAllowCallbacks = $this->allowCallbacks;
24✔
727

728
        return $eventData['data'];
24✔
729
    }
730

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

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

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

758
        return $response;
26✔
759
    }
760

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

772
        return ! ($id === null || $id === [] || $id === '');
27✔
773
    }
774

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

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

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

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

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

811
            return false;
19✔
812
        }
813

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

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

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

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

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

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

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

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

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

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

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

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

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

874
        return $row;
99✔
875
    }
876

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

889
        return $row;
114✔
890
    }
891

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

910
        if (is_array($set)) {
14✔
911
            foreach ($set as &$row) {
14✔
912
                // If casts are used, convert the data first
913
                if ($this->useCasts()) {
14✔
914
                    $row = $this->performCasting($row);
1✔
915
                } elseif (is_object($row) && ! $row instanceof stdClass) {
13✔
916
                    // If $row is using a custom class with public or protected
917
                    // properties representing the collection elements, we need to grab
918
                    // them as an array.
919
                    $row = $this->objectToArray($row, false, true);
1✔
920
                }
921

922
                // If it's still a stdClass, go ahead and convert to
923
                // an array so doProtectFields and other model methods
924
                // don't have to do special checks.
925
                if (is_object($row)) {
14✔
UNCOV
926
                    $row = (array) $row;
×
927
                }
928

929
                // Convert any Time instances to appropriate $dateFormat
930
                $row = $this->timeToString($row);
14✔
931

932
                // Validate every row.
933
                if (! $this->skipValidation && ! $this->validate($row)) {
14✔
934
                    // Restore $cleanValidationRules
935
                    $this->cleanValidationRules = $cleanValidationRules;
3✔
936

937
                    return false;
3✔
938
                }
939

940
                // Must be called first so we don't
941
                // strip out created_at values.
942
                $row = $this->doProtectFieldsForInsert($row);
11✔
943

944
                // Set created_at and updated_at with same time
945
                $date = $this->setDate();
11✔
946
                $row  = $this->setCreatedField($row, $date);
11✔
947
                $row  = $this->setUpdatedField($row, $date);
11✔
948
            }
949
        }
950

951
        // Restore $cleanValidationRules
952
        $this->cleanValidationRules = $cleanValidationRules;
11✔
953

954
        $eventData = ['data' => $set];
11✔
955

956
        if ($this->tempAllowCallbacks) {
11✔
957
            $eventData = $this->trigger('beforeInsertBatch', $eventData);
11✔
958
        }
959

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

962
        $eventData = [
11✔
963
            'data'   => $eventData['data'],
11✔
964
            'result' => $result,
11✔
965
        ];
11✔
966

967
        if ($this->tempAllowCallbacks) {
11✔
968
            // Trigger afterInsert events with the inserted data and new ID
969
            $this->trigger('afterInsertBatch', $eventData);
11✔
970
        }
971

972
        $this->tempAllowCallbacks = $this->allowCallbacks;
11✔
973

974
        return $result;
11✔
975
    }
976

977
    /**
978
     * Updates a single record in the database. If an object is provided,
979
     * it will attempt to convert it into an array.
980
     *
981
     * @param         array|int|string|null $id
982
     * @param         array|object|null     $row Row data
983
     * @phpstan-param row_array|object|null $row
984
     *
985
     * @throws ReflectionException
986
     */
987
    public function update($id = null, $row = null): bool
988
    {
989
        if (is_bool($id)) {
46✔
990
            throw new InvalidArgumentException('update(): argument #1 ($id) should not be boolean.');
1✔
991
        }
992

993
        if (is_numeric($id) || is_string($id)) {
45✔
994
            $id = [$id];
38✔
995
        }
996

997
        $row = $this->transformDataToArray($row, 'update');
45✔
998

999
        // Validate data before saving.
1000
        if (! $this->skipValidation && ! $this->validate($row)) {
43✔
1001
            return false;
4✔
1002
        }
1003

1004
        // Must be called first, so we don't
1005
        // strip out updated_at values.
1006
        $row = $this->doProtectFields($row);
39✔
1007

1008
        // doProtectFields() can further remove elements from
1009
        // $row, so we need to check for empty dataset again
1010
        if ($row === []) {
39✔
1011
            throw DataException::forEmptyDataset('update');
2✔
1012
        }
1013

1014
        $row = $this->setUpdatedField($row, $this->setDate());
37✔
1015

1016
        $eventData = [
37✔
1017
            'id'   => $id,
37✔
1018
            'data' => $row,
37✔
1019
        ];
37✔
1020

1021
        if ($this->tempAllowCallbacks) {
37✔
1022
            $eventData = $this->trigger('beforeUpdate', $eventData);
37✔
1023
        }
1024

1025
        $eventData = [
37✔
1026
            'id'     => $id,
37✔
1027
            'data'   => $eventData['data'],
37✔
1028
            'result' => $this->doUpdate($id, $eventData['data']),
37✔
1029
        ];
37✔
1030

1031
        if ($this->tempAllowCallbacks) {
36✔
1032
            $this->trigger('afterUpdate', $eventData);
36✔
1033
        }
1034

1035
        $this->tempAllowCallbacks = $this->allowCallbacks;
36✔
1036

1037
        return $eventData['result'];
36✔
1038
    }
1039

1040
    /**
1041
     * Compiles an update and runs the query.
1042
     *
1043
     * @param list<object|row_array>|null $set       an associative array of insert values
1044
     * @param string|null                 $index     The where key
1045
     * @param int                         $batchSize The size of the batch to run
1046
     * @param bool                        $returnSQL True means SQL is returned, false will execute the query
1047
     *
1048
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
1049
     *
1050
     * @throws DatabaseException
1051
     * @throws ReflectionException
1052
     */
1053
    public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
1054
    {
1055
        if (is_array($set)) {
6✔
1056
            foreach ($set as &$row) {
6✔
1057
                // If casts are used, convert the data first
1058
                if ($this->useCasts()) {
6✔
1059
                    $row = $this->performCasting($row);
1✔
1060
                } elseif (is_object($row) && ! $row instanceof stdClass) {
5✔
1061
                    // If $row is using a custom class with public or protected
1062
                    // properties representing the collection elements, we need to grab
1063
                    // them as an array.
1064

1065
                    // For updates the index field is needed even if it is not changed.
1066
                    // So set $onlyChanged to false.
1067
                    $row = $this->objectToArray($row, false, true);
1✔
1068
                }
1069

1070
                // If it's still a stdClass, go ahead and convert to
1071
                // an array so doProtectFields and other model methods
1072
                // don't have to do special checks.
1073
                if (is_object($row)) {
6✔
UNCOV
1074
                    $row = (array) $row;
×
1075
                }
1076

1077
                // Validate data before saving.
1078
                if (! $this->skipValidation && ! $this->validate($row)) {
6✔
1079
                    return false;
1✔
1080
                }
1081

1082
                // Save updateIndex for later
1083
                $updateIndex = $row[$index] ?? null;
5✔
1084

1085
                if ($updateIndex === null) {
5✔
1086
                    throw new InvalidArgumentException(
1✔
1087
                        'The index ("' . $index . '") for updateBatch() is missing in the data: '
1✔
1088
                        . json_encode($row),
1✔
1089
                    );
1✔
1090
                }
1091

1092
                // Must be called first so we don't
1093
                // strip out updated_at values.
1094
                $row = $this->doProtectFields($row);
4✔
1095

1096
                // Restore updateIndex value in case it was wiped out
1097
                $row[$index] = $updateIndex;
4✔
1098

1099
                $row = $this->setUpdatedField($row, $this->setDate());
4✔
1100
            }
1101
        }
1102

1103
        $eventData = ['data' => $set];
4✔
1104

1105
        if ($this->tempAllowCallbacks) {
4✔
1106
            $eventData = $this->trigger('beforeUpdateBatch', $eventData);
4✔
1107
        }
1108

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

1111
        $eventData = [
4✔
1112
            'data'   => $eventData['data'],
4✔
1113
            'result' => $result,
4✔
1114
        ];
4✔
1115

1116
        if ($this->tempAllowCallbacks) {
4✔
1117
            // Trigger afterInsert events with the inserted data and new ID
1118
            $this->trigger('afterUpdateBatch', $eventData);
4✔
1119
        }
1120

1121
        $this->tempAllowCallbacks = $this->allowCallbacks;
4✔
1122

1123
        return $result;
4✔
1124
    }
1125

1126
    /**
1127
     * Deletes a single record from the database where $id matches.
1128
     *
1129
     * @param int|list<int|string>|string|null $id    The rows primary key(s)
1130
     * @param bool                             $purge Allows overriding the soft deletes setting.
1131
     *
1132
     * @return BaseResult|bool
1133
     *
1134
     * @throws DatabaseException
1135
     */
1136
    public function delete($id = null, bool $purge = false)
1137
    {
1138
        if (is_bool($id)) {
40✔
UNCOV
1139
            throw new InvalidArgumentException('delete(): argument #1 ($id) should not be boolean.');
×
1140
        }
1141

1142
        if (! in_array($id, [null, 0, '0'], true) && (is_numeric($id) || is_string($id))) {
40✔
1143
            $id = [$id];
20✔
1144
        }
1145

1146
        $eventData = [
40✔
1147
            'id'    => $id,
40✔
1148
            'purge' => $purge,
40✔
1149
        ];
40✔
1150

1151
        if ($this->tempAllowCallbacks) {
40✔
1152
            $this->trigger('beforeDelete', $eventData);
39✔
1153
        }
1154

1155
        $eventData = [
40✔
1156
            'id'     => $id,
40✔
1157
            'data'   => null,
40✔
1158
            'purge'  => $purge,
40✔
1159
            'result' => $this->doDelete($id, $purge),
40✔
1160
        ];
40✔
1161

1162
        if ($this->tempAllowCallbacks) {
27✔
1163
            $this->trigger('afterDelete', $eventData);
26✔
1164
        }
1165

1166
        $this->tempAllowCallbacks = $this->allowCallbacks;
27✔
1167

1168
        return $eventData['result'];
27✔
1169
    }
1170

1171
    /**
1172
     * Permanently deletes all rows that have been marked as deleted
1173
     * through soft deletes (deleted = 1).
1174
     *
1175
     * @return bool|string Returns a string if in test mode.
1176
     */
1177
    public function purgeDeleted()
1178
    {
1179
        if (! $this->useSoftDeletes) {
2✔
1180
            return true;
1✔
1181
        }
1182

1183
        return $this->doPurgeDeleted();
1✔
1184
    }
1185

1186
    /**
1187
     * Sets $useSoftDeletes value so that we can temporarily override
1188
     * the soft deletes settings. Can be used for all find* methods.
1189
     *
1190
     * @param bool $val Value
1191
     *
1192
     * @return $this
1193
     */
1194
    public function withDeleted(bool $val = true)
1195
    {
1196
        $this->tempUseSoftDeletes = ! $val;
23✔
1197

1198
        return $this;
23✔
1199
    }
1200

1201
    /**
1202
     * Works with the find* methods to return only the rows that
1203
     * have been deleted.
1204
     *
1205
     * @return $this
1206
     */
1207
    public function onlyDeleted()
1208
    {
1209
        $this->tempUseSoftDeletes = false;
1✔
1210
        $this->doOnlyDeleted();
1✔
1211

1212
        return $this;
1✔
1213
    }
1214

1215
    /**
1216
     * Compiles a replace and runs the query.
1217
     *
1218
     * @param row_array|null $row       Row data
1219
     * @param bool           $returnSQL Set to true to return Query String
1220
     *
1221
     * @return BaseResult|false|Query|string
1222
     */
1223
    public function replace(?array $row = null, bool $returnSQL = false)
1224
    {
1225
        // Validate data before saving.
1226
        if (($row !== null) && ! $this->skipValidation && ! $this->validate($row)) {
3✔
1227
            return false;
1✔
1228
        }
1229

1230
        $row = (array) $row;
2✔
1231
        $row = $this->setCreatedField($row, $this->setDate());
2✔
1232
        $row = $this->setUpdatedField($row, $this->setDate());
2✔
1233

1234
        return $this->doReplace($row, $returnSQL);
2✔
1235
    }
1236

1237
    /**
1238
     * Grabs the last error(s) that occurred. If data was validated,
1239
     * it will first check for errors there, otherwise will try to
1240
     * grab the last error from the Database connection.
1241
     *
1242
     * The return array should be in the following format:
1243
     *  ['source' => 'message']
1244
     *
1245
     * @param bool $forceDB Always grab the db error, not validation
1246
     *
1247
     * @return array<string, string>
1248
     */
1249
    public function errors(bool $forceDB = false)
1250
    {
1251
        if ($this->validation === null) {
24✔
UNCOV
1252
            return $this->doErrors();
×
1253
        }
1254

1255
        // Do we have validation errors?
1256
        if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors()) !== []) {
24✔
1257
            return $errors;
22✔
1258
        }
1259

1260
        return $this->doErrors();
2✔
1261
    }
1262

1263
    /**
1264
     * Works with Pager to get the size and offset parameters.
1265
     * Expects a GET variable (?page=2) that specifies the page of results
1266
     * to display.
1267
     *
1268
     * @param int|null $perPage Items per page
1269
     * @param string   $group   Will be used by the pagination library to identify a unique pagination set.
1270
     * @param int|null $page    Optional page number (useful when the page number is provided in different way)
1271
     * @param int      $segment Optional URI segment number (if page number is provided by URI segment)
1272
     *
1273
     * @return array|null
1274
     */
1275
    public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0)
1276
    {
1277
        // Since multiple models may use the Pager, the Pager must be shared.
1278
        $pager = service('pager');
8✔
1279

1280
        if ($segment !== 0) {
8✔
UNCOV
1281
            $pager->setSegment($segment, $group);
×
1282
        }
1283

1284
        $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
8✔
1285
        // Store it in the Pager library, so it can be paginated in the views.
1286
        $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
8✔
1287
        $perPage     = $this->pager->getPerPage($group);
8✔
1288
        $offset      = ($pager->getCurrentPage($group) - 1) * $perPage;
8✔
1289

1290
        return $this->findAll($perPage, $offset);
8✔
1291
    }
1292

1293
    /**
1294
     * It could be used when you have to change default or override current allowed fields.
1295
     *
1296
     * @param array $allowedFields Array with names of fields
1297
     *
1298
     * @return $this
1299
     */
1300
    public function setAllowedFields(array $allowedFields)
1301
    {
1302
        $this->allowedFields = $allowedFields;
10✔
1303

1304
        return $this;
10✔
1305
    }
1306

1307
    /**
1308
     * Sets whether or not we should whitelist data set during
1309
     * updates or inserts against $this->availableFields.
1310
     *
1311
     * @param bool $protect Value
1312
     *
1313
     * @return $this
1314
     */
1315
    public function protect(bool $protect = true)
1316
    {
1317
        $this->protectFields = $protect;
12✔
1318

1319
        return $this;
12✔
1320
    }
1321

1322
    /**
1323
     * Ensures that only the fields that are allowed to be updated are
1324
     * in the data array.
1325
     *
1326
     * @used-by update() to protect against mass assignment vulnerabilities.
1327
     * @used-by updateBatch() to protect against mass assignment vulnerabilities.
1328
     *
1329
     * @param         array     $row Row data
1330
     * @phpstan-param row_array $row
1331
     *
1332
     * @throws DataException
1333
     */
1334
    protected function doProtectFields(array $row): array
1335
    {
1336
        if (! $this->protectFields) {
43✔
1337
            return $row;
2✔
1338
        }
1339

1340
        if ($this->allowedFields === []) {
41✔
UNCOV
1341
            throw DataException::forInvalidAllowedFields(static::class);
×
1342
        }
1343

1344
        foreach (array_keys($row) as $key) {
41✔
1345
            if (! in_array($key, $this->allowedFields, true)) {
41✔
1346
                unset($row[$key]);
23✔
1347
            }
1348
        }
1349

1350
        return $row;
41✔
1351
    }
1352

1353
    /**
1354
     * Ensures that only the fields that are allowed to be inserted are in
1355
     * the data array.
1356
     *
1357
     * @used-by insert() to protect against mass assignment vulnerabilities.
1358
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1359
     *
1360
     * @param         array     $row Row data
1361
     * @phpstan-param row_array $row
1362
     *
1363
     * @throws DataException
1364
     */
1365
    protected function doProtectFieldsForInsert(array $row): array
1366
    {
UNCOV
1367
        return $this->doProtectFields($row);
×
1368
    }
1369

1370
    /**
1371
     * Sets the date or current date if null value is passed.
1372
     *
1373
     * @param int|null $userData An optional PHP timestamp to be converted.
1374
     *
1375
     * @return int|string
1376
     *
1377
     * @throws ModelException
1378
     */
1379
    protected function setDate(?int $userData = null)
1380
    {
1381
        $currentDate = $userData ?? Time::now()->getTimestamp();
133✔
1382

1383
        return $this->intToDate($currentDate);
133✔
1384
    }
1385

1386
    /**
1387
     * A utility function to allow child models to use the type of
1388
     * date/time format that they prefer. This is primarily used for
1389
     * setting created_at, updated_at and deleted_at values, but can be
1390
     * used by inheriting classes.
1391
     *
1392
     * The available time formats are:
1393
     *  - 'int'      - Stores the date as an integer timestamp
1394
     *  - 'datetime' - Stores the data in the SQL datetime format
1395
     *  - 'date'     - Stores the date (only) in the SQL date format.
1396
     *
1397
     * @param int $value value
1398
     *
1399
     * @return int|string
1400
     *
1401
     * @throws ModelException
1402
     */
1403
    protected function intToDate(int $value)
1404
    {
1405
        return match ($this->dateFormat) {
133✔
1406
            'int'      => $value,
36✔
1407
            'datetime' => date($this->db->dateFormat['datetime'], $value),
95✔
1408
            'date'     => date($this->db->dateFormat['date'], $value),
1✔
1409
            default    => throw ModelException::forNoDateFormat(static::class),
133✔
1410
        };
133✔
1411
    }
1412

1413
    /**
1414
     * Converts Time value to string using $this->dateFormat.
1415
     *
1416
     * The available time formats are:
1417
     *  - 'int'      - Stores the date as an integer timestamp
1418
     *  - 'datetime' - Stores the data in the SQL datetime format
1419
     *  - 'date'     - Stores the date (only) in the SQL date format.
1420
     *
1421
     * @param Time $value value
1422
     *
1423
     * @return int|string
1424
     */
1425
    protected function timeToDate(Time $value)
1426
    {
1427
        return match ($this->dateFormat) {
5✔
1428
            'datetime' => $value->format($this->db->dateFormat['datetime']),
3✔
1429
            'date'     => $value->format($this->db->dateFormat['date']),
1✔
1430
            'int'      => $value->getTimestamp(),
1✔
1431
            default    => (string) $value,
5✔
1432
        };
5✔
1433
    }
1434

1435
    /**
1436
     * Set the value of the skipValidation flag.
1437
     *
1438
     * @param bool $skip Value
1439
     *
1440
     * @return $this
1441
     */
1442
    public function skipValidation(bool $skip = true)
1443
    {
1444
        $this->skipValidation = $skip;
2✔
1445

1446
        return $this;
2✔
1447
    }
1448

1449
    /**
1450
     * Allows to set (and reset) validation messages.
1451
     * It could be used when you have to change default or override current validate messages.
1452
     *
1453
     * @param array $validationMessages Value
1454
     *
1455
     * @return $this
1456
     */
1457
    public function setValidationMessages(array $validationMessages)
1458
    {
UNCOV
1459
        $this->validationMessages = $validationMessages;
×
1460

UNCOV
1461
        return $this;
×
1462
    }
1463

1464
    /**
1465
     * Allows to set field wise validation message.
1466
     * It could be used when you have to change default or override current validate messages.
1467
     *
1468
     * @param string $field         Field Name
1469
     * @param array  $fieldMessages Validation messages
1470
     *
1471
     * @return $this
1472
     */
1473
    public function setValidationMessage(string $field, array $fieldMessages)
1474
    {
1475
        $this->validationMessages[$field] = $fieldMessages;
2✔
1476

1477
        return $this;
2✔
1478
    }
1479

1480
    /**
1481
     * Allows to set (and reset) validation rules.
1482
     * It could be used when you have to change default or override current validate rules.
1483
     *
1484
     * @param array<string, array<string, array<string, string>|string>|string> $validationRules Value
1485
     *
1486
     * @return $this
1487
     */
1488
    public function setValidationRules(array $validationRules)
1489
    {
1490
        $this->validationRules = $validationRules;
2✔
1491

1492
        return $this;
2✔
1493
    }
1494

1495
    /**
1496
     * Allows to set field wise validation rules.
1497
     * It could be used when you have to change default or override current validate rules.
1498
     *
1499
     * @param string       $field      Field Name
1500
     * @param array|string $fieldRules Validation rules
1501
     *
1502
     * @return $this
1503
     */
1504
    public function setValidationRule(string $field, $fieldRules)
1505
    {
1506
        $rules = $this->validationRules;
2✔
1507

1508
        // ValidationRules can be either a string, which is the group name,
1509
        // or an array of rules.
1510
        if (is_string($rules)) {
2✔
1511
            $this->ensureValidation();
1✔
1512

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

1515
            $this->validationRules = $rules;
1✔
1516
            $this->validationMessages += $customErrors;
1✔
1517
        }
1518

1519
        $this->validationRules[$field] = $fieldRules;
2✔
1520

1521
        return $this;
2✔
1522
    }
1523

1524
    /**
1525
     * Should validation rules be removed before saving?
1526
     * Most handy when doing updates.
1527
     *
1528
     * @param bool $choice Value
1529
     *
1530
     * @return $this
1531
     */
1532
    public function cleanRules(bool $choice = false)
1533
    {
1534
        $this->cleanValidationRules = $choice;
2✔
1535

1536
        return $this;
2✔
1537
    }
1538

1539
    /**
1540
     * Validate the row data against the validation rules (or the validation group)
1541
     * specified in the class property, $validationRules.
1542
     *
1543
     * @param         array|object     $row Row data
1544
     * @phpstan-param row_array|object $row
1545
     */
1546
    public function validate($row): bool
1547
    {
1548
        if ($this->skipValidation) {
151✔
UNCOV
1549
            return true;
×
1550
        }
1551

1552
        $rules = $this->getValidationRules();
151✔
1553

1554
        if ($rules === []) {
151✔
1555
            return true;
105✔
1556
        }
1557

1558
        // Validation requires array, so cast away.
1559
        if (is_object($row)) {
46✔
1560
            $row = (array) $row;
2✔
1561
        }
1562

1563
        if ($row === []) {
46✔
UNCOV
1564
            return true;
×
1565
        }
1566

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

1569
        // If no data existed that needs validation
1570
        // our job is done here.
1571
        if ($rules === []) {
46✔
1572
            return true;
2✔
1573
        }
1574

1575
        $this->ensureValidation();
44✔
1576

1577
        $this->validation->reset()->setRules($rules, $this->validationMessages);
44✔
1578

1579
        return $this->validation->run($row, null, $this->DBGroup);
44✔
1580
    }
1581

1582
    /**
1583
     * Returns the model's defined validation rules so that they
1584
     * can be used elsewhere, if needed.
1585
     *
1586
     * @param array $options Options
1587
     */
1588
    public function getValidationRules(array $options = []): array
1589
    {
1590
        $rules = $this->validationRules;
153✔
1591

1592
        // ValidationRules can be either a string, which is the group name,
1593
        // or an array of rules.
1594
        if (is_string($rules)) {
153✔
1595
            $this->ensureValidation();
13✔
1596

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

1599
            $this->validationMessages += $customErrors;
13✔
1600
        }
1601

1602
        if (isset($options['except'])) {
153✔
UNCOV
1603
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1604
        } elseif (isset($options['only'])) {
153✔
UNCOV
1605
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1606
        }
1607

1608
        return $rules;
153✔
1609
    }
1610

1611
    protected function ensureValidation(): void
1612
    {
1613
        if ($this->validation === null) {
45✔
1614
            $this->validation = service('validation', null, false);
29✔
1615
        }
1616
    }
1617

1618
    /**
1619
     * Returns the model's validation messages, so they
1620
     * can be used elsewhere, if needed.
1621
     */
1622
    public function getValidationMessages(): array
1623
    {
1624
        return $this->validationMessages;
2✔
1625
    }
1626

1627
    /**
1628
     * Removes any rules that apply to fields that have not been set
1629
     * currently so that rules don't block updating when only updating
1630
     * a partial row.
1631
     *
1632
     * @param         array     $rules Array containing field name and rule
1633
     * @param         array     $row   Row data (@TODO Remove null in param type)
1634
     * @phpstan-param row_array $row
1635
     */
1636
    protected function cleanValidationRules(array $rules, ?array $row = null): array
1637
    {
1638
        if ($row === null || $row === []) {
21✔
1639
            return [];
2✔
1640
        }
1641

1642
        foreach (array_keys($rules) as $field) {
19✔
1643
            if (! array_key_exists($field, $row)) {
19✔
1644
                unset($rules[$field]);
8✔
1645
            }
1646
        }
1647

1648
        return $rules;
19✔
1649
    }
1650

1651
    /**
1652
     * Sets $tempAllowCallbacks value so that we can temporarily override
1653
     * the setting. Resets after the next method that uses triggers.
1654
     *
1655
     * @param bool $val value
1656
     *
1657
     * @return $this
1658
     */
1659
    public function allowCallbacks(bool $val = true)
1660
    {
1661
        $this->tempAllowCallbacks = $val;
3✔
1662

1663
        return $this;
3✔
1664
    }
1665

1666
    /**
1667
     * A simple event trigger for Model Events that allows additional
1668
     * data manipulation within the model. Specifically intended for
1669
     * usage by child models this can be used to format data,
1670
     * save/load related classes, etc.
1671
     *
1672
     * It is the responsibility of the callback methods to return
1673
     * the data itself.
1674
     *
1675
     * Each $eventData array MUST have a 'data' key with the relevant
1676
     * data for callback methods (like an array of key/value pairs to insert
1677
     * or update, an array of results, etc.)
1678
     *
1679
     * If callbacks are not allowed then returns $eventData immediately.
1680
     *
1681
     * @param string $event     Event
1682
     * @param array  $eventData Event Data
1683
     *
1684
     * @return array
1685
     *
1686
     * @throws DataException
1687
     */
1688
    protected function trigger(string $event, array $eventData)
1689
    {
1690
        // Ensure it's a valid event
1691
        if (! isset($this->{$event}) || $this->{$event} === []) {
193✔
1692
            return $eventData;
178✔
1693
        }
1694

1695
        foreach ($this->{$event} as $callback) {
15✔
1696
            if (! method_exists($this, $callback)) {
15✔
1697
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1698
            }
1699

1700
            $eventData = $this->{$callback}($eventData);
14✔
1701
        }
1702

1703
        return $eventData;
14✔
1704
    }
1705

1706
    /**
1707
     * If the model is using casts, this will convert the data
1708
     * in $row according to the rules defined in `$casts`.
1709
     *
1710
     * @param object|row_array|null $row Row data
1711
     *
1712
     * @return object|row_array|null Converted row data
1713
     *
1714
     * @used-by insertBatch()
1715
     * @used-by updateBatch()
1716
     *
1717
     * @deprecated Since 4.6.4, temporary solution - will be removed in 4.7
1718
     */
1719
    protected function performCasting(array|object|null $row = null): array|object|null
1720
    {
1721
        if (! $this->useCasts()) {
2✔
NEW
UNCOV
1722
            return $row;
×
1723
        }
1724

1725
        if (is_array($row)) {
2✔
1726
            $row = $this->converter->toDataSource($row);
2✔
NEW
UNCOV
1727
        } elseif ($row instanceof stdClass) {
×
NEW
UNCOV
1728
            $row = (array) $row;
×
NEW
UNCOV
1729
            $row = $this->converter->toDataSource($row);
×
NEW
UNCOV
1730
        } elseif ($row instanceof Entity) {
×
NEW
UNCOV
1731
            $row = $this->converter->extract($row);
×
NEW
UNCOV
1732
        } elseif (is_object($row)) {
×
NEW
UNCOV
1733
            $row = $this->converter->extract($row);
×
1734
        }
1735

1736
        return $row;
2✔
1737
    }
1738

1739
    /**
1740
     * Sets the return type of the results to be as an associative array.
1741
     *
1742
     * @return $this
1743
     */
1744
    public function asArray()
1745
    {
1746
        $this->tempReturnType = 'array';
31✔
1747

1748
        return $this;
31✔
1749
    }
1750

1751
    /**
1752
     * Sets the return type to be of the specified type of object.
1753
     * Defaults to a simple object, but can be any class that has
1754
     * class vars with the same name as the collection columns,
1755
     * or at least allows them to be created.
1756
     *
1757
     * @param 'object'|class-string $class Class Name
1758
     *
1759
     * @return $this
1760
     */
1761
    public function asObject(string $class = 'object')
1762
    {
1763
        $this->tempReturnType = $class;
18✔
1764

1765
        return $this;
18✔
1766
    }
1767

1768
    /**
1769
     * Takes a class and returns an array of its public and protected
1770
     * properties as an array suitable for use in creates and updates.
1771
     * This method uses objectToRawArray() internally and does conversion
1772
     * to string on all Time instances
1773
     *
1774
     * @param object $object      Object
1775
     * @param bool   $onlyChanged Only Changed Property
1776
     * @param bool   $recursive   If true, inner entities will be cast as array as well
1777
     *
1778
     * @return array<string, mixed>
1779
     *
1780
     * @throws ReflectionException
1781
     */
1782
    protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
1783
    {
1784
        $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
24✔
1785

1786
        // Convert any Time instances to appropriate $dateFormat
1787
        return $this->timeToString($properties);
24✔
1788
    }
1789

1790
    /**
1791
     * Convert any Time instances to appropriate $dateFormat.
1792
     *
1793
     * @param array<string, mixed> $properties
1794
     *
1795
     * @return array<string, mixed>
1796
     */
1797
    protected function timeToString(array $properties): array
1798
    {
1799
        if ($properties === []) {
137✔
1800
            return [];
1✔
1801
        }
1802

1803
        return array_map(function ($value) {
136✔
1804
            if ($value instanceof Time) {
136✔
1805
                return $this->timeToDate($value);
5✔
1806
            }
1807

1808
            return $value;
136✔
1809
        }, $properties);
136✔
1810
    }
1811

1812
    /**
1813
     * Takes a class and returns an array of its public and protected
1814
     * properties as an array with raw values.
1815
     *
1816
     * @param object $object      Object
1817
     * @param bool   $onlyChanged Only Changed Property
1818
     * @param bool   $recursive   If true, inner entities will be casted as array as well
1819
     *
1820
     * @return array<string, mixed> Array with raw values.
1821
     *
1822
     * @throws ReflectionException
1823
     */
1824
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
1825
    {
1826
        // Entity::toRawArray() returns array.
1827
        if (method_exists($object, 'toRawArray')) {
24✔
1828
            $properties = $object->toRawArray($onlyChanged, $recursive);
22✔
1829
        } else {
1830
            $mirror = new ReflectionClass($object);
2✔
1831
            $props  = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
2✔
1832

1833
            $properties = [];
2✔
1834

1835
            // Loop over each property,
1836
            // saving the name/value in a new array we can return.
1837
            foreach ($props as $prop) {
2✔
1838
                $properties[$prop->getName()] = $prop->getValue($object);
2✔
1839
            }
1840
        }
1841

1842
        return $properties;
24✔
1843
    }
1844

1845
    /**
1846
     * Transform data to array.
1847
     *
1848
     * @param object|row_array|null $row  Row data
1849
     * @param string                $type Type of data (insert|update)
1850
     *
1851
     * @throws DataException
1852
     * @throws InvalidArgumentException
1853
     * @throws ReflectionException
1854
     *
1855
     * @used-by insert()
1856
     * @used-by update()
1857
     */
1858
    protected function transformDataToArray($row, string $type): array
1859
    {
1860
        if (! in_array($type, ['insert', 'update'], true)) {
126✔
1861
            throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
1✔
1862
        }
1863

1864
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
125✔
1865
            throw DataException::forEmptyDataset($type);
6✔
1866
        }
1867

1868
        // If it validates with entire rules, all fields are needed.
1869
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
122✔
1870
            $onlyChanged = false;
109✔
1871
        } else {
1872
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
42✔
1873
        }
1874

1875
        if ($this->useCasts()) {
122✔
1876
            if (is_array($row)) {
25✔
1877
                $row = $this->converter->toDataSource($row);
25✔
1878
            } elseif ($row instanceof stdClass) {
7✔
1879
                $row = (array) $row;
3✔
1880
                $row = $this->converter->toDataSource($row);
3✔
1881
            } elseif ($row instanceof Entity) {
4✔
1882
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1883
            } elseif (is_object($row)) {
2✔
1884
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1885
            }
1886
        }
1887
        // If $row is using a custom class with public or protected
1888
        // properties representing the collection elements, we need to grab
1889
        // them as an array.
1890
        elseif (is_object($row) && ! $row instanceof stdClass) {
97✔
1891
            $row = $this->objectToArray($row, $onlyChanged, true);
21✔
1892
        }
1893

1894
        // If it's still a stdClass, go ahead and convert to
1895
        // an array so doProtectFields and other model methods
1896
        // don't have to do special checks.
1897
        if (is_object($row)) {
122✔
1898
            $row = (array) $row;
13✔
1899
        }
1900

1901
        // If it's still empty here, means $row is no change or is empty object
1902
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
122✔
UNCOV
1903
            throw DataException::forEmptyDataset($type);
×
1904
        }
1905

1906
        // Convert any Time instances to appropriate $dateFormat
1907
        return $this->timeToString($row);
122✔
1908
    }
1909

1910
    /**
1911
     * Provides the db connection and model's properties.
1912
     *
1913
     * @param string $name Name
1914
     *
1915
     * @return array|bool|float|int|object|string|null
1916
     */
1917
    public function __get(string $name)
1918
    {
1919
        if (property_exists($this, $name)) {
50✔
1920
            return $this->{$name};
50✔
1921
        }
1922

1923
        return $this->db->{$name} ?? null;
1✔
1924
    }
1925

1926
    /**
1927
     * Checks for the existence of properties across this model, and db connection.
1928
     *
1929
     * @param string $name Name
1930
     */
1931
    public function __isset(string $name): bool
1932
    {
1933
        if (property_exists($this, $name)) {
50✔
1934
            return true;
50✔
1935
        }
1936

1937
        return isset($this->db->{$name});
1✔
1938
    }
1939

1940
    /**
1941
     * Provides direct access to method in the database connection.
1942
     *
1943
     * @param string $name   Name
1944
     * @param array  $params Params
1945
     *
1946
     * @return $this|null
1947
     */
1948
    public function __call(string $name, array $params)
1949
    {
UNCOV
1950
        if (method_exists($this->db, $name)) {
×
UNCOV
1951
            return $this->db->{$name}(...$params);
×
1952
        }
1953

UNCOV
1954
        return null;
×
1955
    }
1956

1957
    /**
1958
     * Sets $allowEmptyInserts.
1959
     */
1960
    public function allowEmptyInserts(bool $value = true): self
1961
    {
1962
        $this->allowEmptyInserts = $value;
1✔
1963

1964
        return $this;
1✔
1965
    }
1966

1967
    /**
1968
     * Converts database data array to return type value.
1969
     *
1970
     * @param array<string, mixed>          $row        Raw data from database
1971
     * @param 'array'|'object'|class-string $returnType
1972
     */
1973
    protected function convertToReturnType(array $row, string $returnType): array|object
1974
    {
1975
        if ($returnType === 'array') {
25✔
1976
            return $this->converter->fromDataSource($row);
10✔
1977
        }
1978

1979
        if ($returnType === 'object') {
17✔
1980
            return (object) $this->converter->fromDataSource($row);
5✔
1981
        }
1982

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

© 2025 Coveralls, Inc