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

codeigniter4 / CodeIgniter4 / 27436349931

12 Jun 2026 06:53PM UTC coverage: 89.066% (+0.08%) from 88.991%
27436349931

Pull #10302

github

web-flow
Merge bad5eb579 into 130098704
Pull Request #10302: feat: add strict field protection for Models

18 of 21 new or added lines in 4 files covered. (85.71%)

12 existing lines in 5 files now uncovered.

24854 of 27905 relevant lines covered (89.07%)

225.51 hits per line

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

95.12
/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\Exceptions\UniqueConstraintViolationException;
22
use CodeIgniter\Database\Query;
23
use CodeIgniter\Database\RawSql;
24
use CodeIgniter\DataCaster\Cast\CastInterface;
25
use CodeIgniter\DataConverter\DataConverter;
26
use CodeIgniter\Entity\Cast\CastInterface as EntityCastInterface;
27
use CodeIgniter\Entity\Entity;
28
use CodeIgniter\Exceptions\InvalidArgumentException;
29
use CodeIgniter\Exceptions\ModelException;
30
use CodeIgniter\I18n\Time;
31
use CodeIgniter\Pager\Pager;
32
use CodeIgniter\Validation\ValidationInterface;
33
use Config\Feature;
34
use ReflectionClass;
35
use ReflectionException;
36
use ReflectionProperty;
37
use stdClass;
38

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

74
    /**
75
     * Database Connection.
76
     *
77
     * @var BaseConnection
78
     */
79
    protected $db;
80

81
    /**
82
     * Last insert ID.
83
     *
84
     * @var int|string
85
     */
86
    protected $insertID = 0;
87

88
    /**
89
     * The Database connection group that
90
     * should be instantiated.
91
     *
92
     * @var non-empty-string|null
93
     */
94
    protected $DBGroup;
95

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

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

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

122
    /**
123
     * Custom convert handlers.
124
     *
125
     * @var array<string, class-string<CastInterface|EntityCastInterface>> Array order `['type' => 'classname']`.
126
     */
127
    protected array $castHandlers = [];
128

129
    protected ?DataConverter $converter = null;
130

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

144
    /**
145
     * Whether Model should throw instead of silently discarding
146
     * fields that are not in $allowedFields.
147
     */
148
    protected bool $throwOnDisallowedFields = false;
149

150
    /**
151
     * An array of field names that are allowed
152
     * to be set by the user in inserts/updates.
153
     *
154
     * @var list<string>
155
     */
156
    protected $allowedFields = [];
157

158
    /**
159
     * If true, will set created_at, and updated_at
160
     * values during insert and update routines.
161
     *
162
     * @var bool
163
     */
164
    protected $useTimestamps = false;
165

166
    /**
167
     * The type of column that created_at and updated_at
168
     * are expected to.
169
     *
170
     * @var 'date'|'datetime'|'int'
171
     */
172
    protected $dateFormat = 'datetime';
173

174
    /**
175
     * The column used for insert timestamps.
176
     *
177
     * @var string
178
     */
179
    protected $createdField = 'created_at';
180

181
    /**
182
     * The column used for update timestamps.
183
     *
184
     * @var string
185
     */
186
    protected $updatedField = 'updated_at';
187

188
    /**
189
     * If this model should use "softDeletes" and
190
     * simply set a date when rows are deleted, or
191
     * do hard deletes.
192
     *
193
     * @var bool
194
     */
195
    protected $useSoftDeletes = false;
196

197
    /**
198
     * Used by $this->withDeleted() to override the
199
     * model's "softDelete" setting.
200
     *
201
     * @var bool
202
     */
203
    protected $tempUseSoftDeletes;
204

205
    /**
206
     * The column used to save soft delete state.
207
     *
208
     * @var string
209
     */
210
    protected $deletedField = 'deleted_at';
211

212
    /**
213
     * Whether to allow inserting empty data.
214
     */
215
    protected bool $allowEmptyInserts = false;
216

217
    /**
218
     * Whether to update Entity's only changed data.
219
     */
220
    protected bool $updateOnlyChanged = true;
221

222
    /**
223
     * Rules used to validate data in insert(), update(), save(),
224
     * insertBatch(), and updateBatch() methods.
225
     *
226
     * The array must match the format of data passed to the `Validation`
227
     * library.
228
     *
229
     * @see https://codeigniter4.github.io/userguide/models/model.html#setting-validation-rules
230
     *
231
     * @var array<string, array<string, array<string, string>|string>|string>|string
232
     */
233
    protected $validationRules = [];
234

235
    /**
236
     * Contains any custom error messages to be
237
     * used during data validation.
238
     *
239
     * @var array<string, array<string, string>> The column is used as the keys.
240
     */
241
    protected $validationMessages = [];
242

243
    /**
244
     * Skip the model's validation.
245
     *
246
     * Used in conjunction with `$this->skipValidation()`
247
     * to skip data validation for any future calls.
248
     *
249
     * @var bool
250
     */
251
    protected $skipValidation = false;
252

253
    /**
254
     * Whether rules should be removed that do not exist
255
     * in the passed data. Used in updates.
256
     *
257
     * @var bool
258
     */
259
    protected $cleanValidationRules = true;
260

261
    /**
262
     * Our validator instance.
263
     *
264
     * @var ValidationInterface|null
265
     */
266
    protected $validation;
267

268
    /*
269
     * Callbacks.
270
     *
271
     * Each array should contain the method names (within the model)
272
     * that should be called when those events are triggered.
273
     *
274
     * "Update" and "delete" methods are passed the same items that
275
     * are given to their respective method.
276
     *
277
     * "Find" methods receive the ID searched for (if present), and
278
     * 'afterFind' additionally receives the results that were found.
279
     */
280

281
    /**
282
     * Whether to trigger the defined callbacks.
283
     *
284
     * @var bool
285
     */
286
    protected $allowCallbacks = true;
287

288
    /**
289
     * Used by $this->allowCallbacks() to override the
290
     * model's $allowCallbacks setting.
291
     *
292
     * @var bool
293
     */
294
    protected $tempAllowCallbacks;
295

296
    /**
297
     * Callbacks for "beforeInsert" event.
298
     *
299
     * @var list<string>
300
     */
301
    protected $beforeInsert = [];
302

303
    /**
304
     * Callbacks for "afterInsert" event.
305
     *
306
     * @var list<string>
307
     */
308
    protected $afterInsert = [];
309

310
    /**
311
     * Callbacks for "beforeUpdate" event.
312
     *
313
     * @var list<string>
314
     */
315
    protected $beforeUpdate = [];
316

317
    /**
318
     * Callbacks for "afterUpdate" event.
319
     *
320
     * @var list<string>
321
     */
322
    protected $afterUpdate = [];
323

324
    /**
325
     * Callbacks for "beforeInsertBatch" event.
326
     *
327
     * @var list<string>
328
     */
329
    protected $beforeInsertBatch = [];
330

331
    /**
332
     * Callbacks for "afterInsertBatch" event.
333
     *
334
     * @var list<string>
335
     */
336
    protected $afterInsertBatch = [];
337

338
    /**
339
     * Callbacks for "beforeUpdateBatch" event.
340
     *
341
     * @var list<string>
342
     */
343
    protected $beforeUpdateBatch = [];
344

345
    /**
346
     * Callbacks for "afterUpdateBatch" event.
347
     *
348
     * @var list<string>
349
     */
350
    protected $afterUpdateBatch = [];
351

352
    /**
353
     * Callbacks for "beforeFind" event.
354
     *
355
     * @var list<string>
356
     */
357
    protected $beforeFind = [];
358

359
    /**
360
     * Callbacks for "afterFind" event.
361
     *
362
     * @var list<string>
363
     */
364
    protected $afterFind = [];
365

366
    /**
367
     * Callbacks for "beforeDelete" event.
368
     *
369
     * @var list<string>
370
     */
371
    protected $beforeDelete = [];
372

373
    /**
374
     * Callbacks for "afterDelete" event.
375
     *
376
     * @var list<string>
377
     */
378
    protected $afterDelete = [];
379

380
    public function __construct(?ValidationInterface $validation = null)
381
    {
382
        $this->tempReturnType     = $this->returnType;
432✔
383
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
432✔
384
        $this->tempAllowCallbacks = $this->allowCallbacks;
432✔
385

386
        $this->validation = $validation;
432✔
387

388
        $this->initialize();
432✔
389
        $this->createDataConverter();
432✔
390
    }
391

392
    /**
393
     * Creates DataConverter instance.
394
     */
395
    protected function createDataConverter(): void
396
    {
397
        if ($this->useCasts()) {
432✔
398
            $this->converter = new DataConverter(
30✔
399
                $this->casts,
30✔
400
                $this->castHandlers,
30✔
401
                $this->db,
30✔
402
            );
30✔
403
        }
404
    }
405

406
    /**
407
     * Are casts used?
408
     */
409
    protected function useCasts(): bool
410
    {
411
        return $this->casts !== [];
432✔
412
    }
413

414
    /**
415
     * Initializes the instance with any additional steps.
416
     * Optionally implemented by child classes.
417
     *
418
     * @return void
419
     */
420
    protected function initialize()
421
    {
422
    }
431✔
423

424
    /**
425
     * Fetches the row(s) of database with a primary key
426
     * matching $id.
427
     * This method works only with DB calls.
428
     *
429
     * @param bool                             $singleton Single or multiple results.
430
     * @param int|list<int|string>|string|null $id        One primary key or an array of primary keys.
431
     *
432
     * @return ($singleton is true ? object|row_array|null : list<object|row_array>) The resulting row of data or `null`.
433
     */
434
    abstract protected function doFind(bool $singleton, $id = null);
435

436
    /**
437
     * Fetches the column of database.
438
     * This method works only with DB calls.
439
     *
440
     * @return list<row_array>|null The resulting row of data or `null` if no data found.
441
     *
442
     * @throws DataException
443
     */
444
    abstract protected function doFindColumn(string $columnName);
445

446
    /**
447
     * Fetches all results, while optionally limiting them.
448
     * This method works only with DB calls.
449
     *
450
     * @return list<object|row_array>
451
     */
452
    abstract protected function doFindAll(?int $limit = null, int $offset = 0);
453

454
    /**
455
     * Returns the first row of the result set.
456
     * This method works only with DB calls.
457
     *
458
     * @return object|row_array|null
459
     */
460
    abstract protected function doFirst();
461

462
    /**
463
     * Inserts data into the current database.
464
     * This method works only with DB calls.
465
     *
466
     * @param row_array $row
467
     *
468
     * @return bool
469
     */
470
    abstract protected function doInsert(array $row);
471

472
    /**
473
     * Compiles batch insert and runs the queries, validating each row prior.
474
     * This method works only with DB calls.
475
     *
476
     * @param list<object|row_array>|null $set       An associative array of insert values.
477
     * @param bool|null                   $escape    Whether to escape values.
478
     * @param int                         $batchSize The size of the batch to run.
479
     * @param bool                        $testing   `true` means only number of records is returned, `false` will execute the query.
480
     *
481
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode
482
     */
483
    abstract protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false);
484

485
    /**
486
     * Updates a single record in the database.
487
     * This method works only with DB calls.
488
     *
489
     * @param int|list<int|string>|string|null $id
490
     * @param row_array|null                   $row
491
     */
492
    abstract protected function doUpdate($id = null, $row = null): bool;
493

494
    /**
495
     * Compiles an update and runs the query.
496
     * This method works only with DB calls.
497
     *
498
     * @param list<object|row_array>|null $set       An associative array of update values.
499
     * @param string|null                 $index     The where key.
500
     * @param int                         $batchSize The size of the batch to run.
501
     * @param bool                        $returnSQL `true` means SQL is returned, `false` will execute the query.
502
     *
503
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode
504
     *
505
     * @throws DatabaseException
506
     */
507
    abstract protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false);
508

509
    /**
510
     * Deletes a single record from the database where $id matches
511
     * the table's primary key.
512
     * This method works only with DB calls.
513
     *
514
     * @param int|list<int|string>|string|null $id    The rows primary key(s).
515
     * @param bool                             $purge Allows overriding the soft deletes setting.
516
     *
517
     * @return bool|string Returns a SQL string if in test mode.
518
     *
519
     * @throws DatabaseException
520
     */
521
    abstract protected function doDelete($id = null, bool $purge = false);
522

523
    /**
524
     * Permanently deletes all rows that have been marked as deleted
525
     * through soft deletes (value of column $deletedField is not null).
526
     * This method works only with DB calls.
527
     *
528
     * @return bool|string Returns a SQL string if in test mode.
529
     */
530
    abstract protected function doPurgeDeleted();
531

532
    /**
533
     * Works with the $this->find* methods to return only the rows that
534
     * have been deleted (value of column $deletedField is not null).
535
     * This method works only with DB calls.
536
     *
537
     * @return void
538
     */
539
    abstract protected function doOnlyDeleted();
540

541
    /**
542
     * Compiles a replace and runs the query.
543
     * This method works only with DB calls.
544
     *
545
     * @param row_array|null $row
546
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query.
547
     *
548
     * @return BaseResult|false|Query|string
549
     */
550
    abstract protected function doReplace(?array $row = null, bool $returnSQL = false);
551

552
    /**
553
     * Grabs the last error(s) that occurred from the Database connection.
554
     * This method works only with DB calls.
555
     *
556
     * @return array<string, string>
557
     */
558
    abstract protected function doErrors();
559

560
    /**
561
     * Public getter to return the ID value for the data array or object.
562
     * For example with SQL this will return `$data->{$this->primaryKey}`.
563
     *
564
     * @param object|row_array $row
565
     *
566
     * @return int|string|null
567
     */
568
    abstract public function getIdValue($row);
569

570
    /**
571
     * Override countAllResults to account for soft deleted accounts.
572
     * This method works only with DB calls.
573
     *
574
     * @param bool $reset When `false`, the `$tempUseSoftDeletes` will be
575
     *                    dependent on `$useSoftDeletes` value because we don't
576
     *                    want to add the same "where" condition for the second time.
577
     * @param bool $test  `true` returns the number of all records, `false` will execute the query.
578
     *
579
     * @return int|string Returns a SQL string if in test mode.
580
     */
581
    abstract public function countAllResults(bool $reset = true, bool $test = false);
582

583
    /**
584
     * Loops over records in batches, allowing you to operate on them.
585
     * This method works only with DB calls.
586
     *
587
     * @param Closure(array<string, string>|object): mixed $userFunc
588
     *
589
     * @return void
590
     *
591
     * @throws DataException
592
     * @throws InvalidArgumentException if $size is not a positive integer
593
     */
594
    abstract public function chunk(int $size, Closure $userFunc);
595

596
    /**
597
     * Loops over records in batches, allowing you to operate on each chunk at a time.
598
     * This method works only with DB calls.
599
     *
600
     * This method calls the `$userFunc` with the chunk, instead of a single record as in `chunk()`.
601
     * This allows you to operate on multiple records at once, which can be more efficient for certain operations.
602
     *
603
     * @param Closure(list<array<string, string>>|list<object>): mixed $userFunc
604
     *
605
     * @return void
606
     *
607
     * @throws DataException
608
     * @throws InvalidArgumentException if $size is not a positive integer
609
     */
610
    abstract public function chunkRows(int $size, Closure $userFunc);
611

612
    /**
613
     * Fetches the row of database.
614
     *
615
     * @param int|list<int|string>|string|null $id One primary key or an array of primary keys.
616
     *
617
     * @return ($id is int|string ? object|row_array|null :  list<object|row_array>)
618
     */
619
    public function find($id = null)
620
    {
621
        $singleton = is_numeric($id) || is_string($id);
58✔
622

623
        if ($this->tempAllowCallbacks) {
58✔
624
            // Call the before event and check for a return
625
            $eventData = $this->trigger('beforeFind', [
55✔
626
                'id'        => $id,
55✔
627
                'method'    => 'find',
55✔
628
                'singleton' => $singleton,
55✔
629
            ]);
55✔
630

631
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
55✔
632
                return $eventData['data'];
3✔
633
            }
634
        }
635

636
        $eventData = [
56✔
637
            'id'        => $id,
56✔
638
            'data'      => $this->doFind($singleton, $id),
56✔
639
            'method'    => 'find',
56✔
640
            'singleton' => $singleton,
56✔
641
        ];
56✔
642

643
        if ($this->tempAllowCallbacks) {
55✔
644
            $eventData = $this->trigger('afterFind', $eventData);
52✔
645
        }
646

647
        $this->tempReturnType     = $this->returnType;
55✔
648
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
55✔
649
        $this->tempAllowCallbacks = $this->allowCallbacks;
55✔
650

651
        return $eventData['data'];
55✔
652
    }
653

654
    /**
655
     * Fetches the column of database.
656
     *
657
     * @return list<bool|float|int|list<mixed>|object|string|null>|null The resulting row of data, or `null` if no data found.
658
     *
659
     * @throws DataException
660
     */
661
    public function findColumn(string $columnName)
662
    {
663
        if (str_contains($columnName, ',')) {
3✔
664
            throw DataException::forFindColumnHaveMultipleColumns();
1✔
665
        }
666

667
        $resultSet = $this->doFindColumn($columnName);
2✔
668

669
        return $resultSet !== null ? array_column($resultSet, $columnName) : null;
2✔
670
    }
671

672
    /**
673
     * Fetches all results, while optionally limiting them.
674
     *
675
     * @return list<object|row_array>
676
     */
677
    public function findAll(?int $limit = null, int $offset = 0)
678
    {
679
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
22✔
680
        if ($limitZeroAsAll) {
22✔
681
            $limit ??= 0;
22✔
682
        }
683

684
        if ($this->tempAllowCallbacks) {
22✔
685
            // Call the before event and check for a return
686
            $eventData = $this->trigger('beforeFind', [
22✔
687
                'method'    => 'findAll',
22✔
688
                'limit'     => $limit,
22✔
689
                'offset'    => $offset,
22✔
690
                'singleton' => false,
22✔
691
            ]);
22✔
692

693
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
22✔
694
                return $eventData['data'];
1✔
695
            }
696
        }
697

698
        $eventData = [
22✔
699
            'data'      => $this->doFindAll($limit, $offset),
22✔
700
            'limit'     => $limit,
22✔
701
            'offset'    => $offset,
22✔
702
            'method'    => 'findAll',
22✔
703
            'singleton' => false,
22✔
704
        ];
22✔
705

706
        if ($this->tempAllowCallbacks) {
22✔
707
            $eventData = $this->trigger('afterFind', $eventData);
22✔
708
        }
709

710
        $this->tempReturnType     = $this->returnType;
22✔
711
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
22✔
712
        $this->tempAllowCallbacks = $this->allowCallbacks;
22✔
713

714
        return $eventData['data'];
22✔
715
    }
716

717
    /**
718
     * Returns the first row of the result set.
719
     *
720
     * @return object|row_array|null
721
     */
722
    public function first()
723
    {
724
        if ($this->tempAllowCallbacks) {
36✔
725
            // Call the before event and check for a return
726
            $eventData = $this->trigger('beforeFind', [
36✔
727
                'method'    => 'first',
36✔
728
                'singleton' => true,
36✔
729
            ]);
36✔
730

731
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
36✔
732
                return $eventData['data'];
1✔
733
            }
734
        }
735

736
        $eventData = [
36✔
737
            'data'      => $this->doFirst(),
36✔
738
            'method'    => 'first',
36✔
739
            'singleton' => true,
36✔
740
        ];
36✔
741

742
        if ($this->tempAllowCallbacks) {
36✔
743
            $eventData = $this->trigger('afterFind', $eventData);
36✔
744
        }
745

746
        $this->tempReturnType     = $this->returnType;
36✔
747
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
36✔
748
        $this->tempAllowCallbacks = $this->allowCallbacks;
36✔
749

750
        return $eventData['data'];
36✔
751
    }
752

753
    /**
754
     * A convenience method that will attempt to determine whether the
755
     * data should be inserted or updated.
756
     *
757
     * Will work with either an array or object.
758
     * When using with custom class objects,
759
     * you must ensure that the class will provide access to the class
760
     * variables, even if through a magic method.
761
     *
762
     * @param object|row_array $row
763
     *
764
     * @throws ReflectionException
765
     */
766
    public function save($row): bool
767
    {
768
        if ((array) $row === []) {
29✔
769
            return true;
1✔
770
        }
771

772
        if ($this->shouldUpdate($row)) {
28✔
773
            $response = $this->update($this->getIdValue($row), $row);
17✔
774
        } else {
775
            $response = $this->insert($row, false);
15✔
776

777
            if ($response !== false) {
13✔
778
                $response = true;
12✔
779
            }
780
        }
781

782
        return $response;
26✔
783
    }
784

785
    /**
786
     * This method is called on save to determine if entry have to be updated.
787
     * If this method returns `false` insert operation will be executed.
788
     *
789
     * @param object|row_array $row
790
     */
791
    protected function shouldUpdate($row): bool
792
    {
793
        $id = $this->getIdValue($row);
28✔
794

795
        return ! in_array($id, [null, [], ''], true);
28✔
796
    }
797

798
    /**
799
     * Returns last insert ID or 0.
800
     *
801
     * @return int|string
802
     */
803
    public function getInsertID()
804
    {
805
        return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID;
11✔
806
    }
807

808
    /**
809
     * Validates that the primary key values are valid for update/delete/insert operations.
810
     * Throws exception if invalid.
811
     *
812
     * @param bool $allowArray Whether to allow array of IDs (true for update/delete, false for insert)
813
     *
814
     * @phpstan-assert non-zero-int|non-empty-list<int|string>|RawSql|non-falsy-string $id
815
     * @throws         InvalidArgumentException
816
     */
817
    protected function validateID(mixed $id, bool $allowArray = true): void
818
    {
819
        if (is_array($id)) {
149✔
820
            // Check if arrays are allowed
821
            if (! $allowArray) {
135✔
822
                throw new InvalidArgumentException(
8✔
823
                    'Invalid primary key: only a single value is allowed, not an array.',
8✔
824
                );
8✔
825
            }
826

827
            // Check for empty array
828
            if ($id === []) {
127✔
829
                throw new InvalidArgumentException('Invalid primary key: cannot be an empty array.');
5✔
830
            }
831

832
            // Validate each ID in the array recursively
833
            foreach ($id as $key => $valueId) {
122✔
834
                if (is_array($valueId)) {
122✔
835
                    throw new InvalidArgumentException(
5✔
836
                        sprintf('Invalid primary key at index %s: nested arrays are not allowed.', $key),
5✔
837
                    );
5✔
838
                }
839

840
                // Recursive call for each value (single values only in recursion)
841
                $this->validateID($valueId, false);
117✔
842
            }
843

844
            return;
67✔
845
        }
846

847
        // Allow RawSql objects for complex scenarios
848
        if ($id instanceof RawSql) {
135✔
849
            return;
2✔
850
        }
851

852
        // Check for invalid single values
853
        if (in_array($id, [null, 0, '0', '', true, false], true)) {
133✔
854
            $type = is_bool($id) ? 'boolean ' . var_export($id, true) : var_export($id, true);
60✔
855

856
            throw new InvalidArgumentException(
60✔
857
                sprintf('Invalid primary key: %s is not allowed.', $type),
60✔
858
            );
60✔
859
        }
860

861
        // Only allow int and string at this point
862
        if (! is_int($id) && ! is_string($id)) {
103✔
863
            throw new InvalidArgumentException(
×
864
                sprintf('Invalid primary key: must be int or string, %s given.', get_debug_type($id)),
×
865
            );
×
866
        }
867
    }
868

869
    /**
870
     * Inserts data into the database. If an object is provided,
871
     * it will attempt to convert it to an array.
872
     *
873
     * @param object|row_array|null $row
874
     * @param bool                  $returnID Whether insert ID should be returned or not.
875
     *
876
     * @return ($returnID is true ? false|int|string : bool)
877
     *
878
     * @throws ReflectionException
879
     * @throws UniqueConstraintViolationException
880
     */
881
    public function insert($row = null, bool $returnID = true)
882
    {
883
        $this->insertID = 0;
132✔
884

885
        // Set $cleanValidationRules to false temporary.
886
        $cleanValidationRules       = $this->cleanValidationRules;
132✔
887
        $this->cleanValidationRules = false;
132✔
888

889
        $row = $this->transformDataToArray($row, 'insert');
132✔
890

891
        // Validate data before saving.
892
        if (! $this->skipValidation && ! $this->validate($row)) {
130✔
893
            // Restore $cleanValidationRules
894
            $this->cleanValidationRules = $cleanValidationRules;
21✔
895

896
            return false;
21✔
897
        }
898

899
        // Restore $cleanValidationRules
900
        $this->cleanValidationRules = $cleanValidationRules;
111✔
901

902
        // Must be called first, so we don't
903
        // strip out created_at values.
904
        $row = $this->doProtectFieldsForInsert($row);
111✔
905

906
        // doProtectFields() can further remove elements from
907
        // $row, so we need to check for empty dataset again
908
        if (! $this->allowEmptyInserts && $row === []) {
108✔
909
            throw DataException::forEmptyDataset('insert');
2✔
910
        }
911

912
        // Set created_at and updated_at with same time
913
        $date = $this->setDate();
106✔
914
        $row  = $this->setCreatedField($row, $date);
106✔
915
        $row  = $this->setUpdatedField($row, $date);
106✔
916

917
        $eventData = ['data' => $row];
106✔
918

919
        if ($this->tempAllowCallbacks) {
106✔
920
            $eventData = $this->trigger('beforeInsert', $eventData);
106✔
921
        }
922

923
        $result = $this->doInsert($eventData['data']);
105✔
924

925
        $eventData = [
93✔
926
            'id'     => $this->insertID,
93✔
927
            'data'   => $eventData['data'],
93✔
928
            'result' => $result,
93✔
929
        ];
93✔
930

931
        if ($this->tempAllowCallbacks) {
93✔
932
            // Trigger afterInsert events with the inserted data and new ID
933
            $this->trigger('afterInsert', $eventData);
93✔
934
        }
935

936
        $this->tempAllowCallbacks = $this->allowCallbacks;
93✔
937

938
        // If insertion failed, get out of here
939
        if (! $result) {
93✔
940
            return $result;
4✔
941
        }
942

943
        // otherwise return the insertID, if requested.
944
        return $returnID ? $this->insertID : $result;
89✔
945
    }
946

947
    /**
948
     * Set datetime to created field.
949
     *
950
     * @param row_array  $row
951
     * @param int|string $date Timestamp or datetime string.
952
     *
953
     * @return row_array
954
     */
955
    protected function setCreatedField(array $row, $date): array
956
    {
957
        if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) {
128✔
958
            $row[$this->createdField] = $date;
39✔
959
        }
960

961
        return $row;
128✔
962
    }
963

964
    /**
965
     * Set datetime to updated field.
966
     *
967
     * @param row_array  $row
968
     * @param int|string $date Timestamp or datetime string
969
     *
970
     * @return row_array
971
     */
972
    protected function setUpdatedField(array $row, $date): array
973
    {
974
        if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) {
149✔
975
            $row[$this->updatedField] = $date;
43✔
976
        }
977

978
        return $row;
149✔
979
    }
980

981
    /**
982
     * Compiles batch insert runs the queries, validating each row prior.
983
     *
984
     * @param list<object|row_array>|null $set       An associative array of insert values.
985
     * @param bool|null                   $escape    Whether to escape values.
986
     * @param int                         $batchSize The size of the batch to run.
987
     * @param bool                        $testing   `true` means only number of records is returned, `false` will execute the query.
988
     *
989
     * @return false|int|list<string> Number of rows inserted or `false` on failure.
990
     *
991
     * @throws ReflectionException
992
     */
993
    public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false)
994
    {
995
        // Set $cleanValidationRules to false temporary.
996
        $cleanValidationRules       = $this->cleanValidationRules;
25✔
997
        $this->cleanValidationRules = false;
25✔
998

999
        if (is_array($set)) {
25✔
1000
            foreach ($set as &$row) {
25✔
1001
                $row = $this->transformDataToArray($row, 'insert');
25✔
1002

1003
                // Validate every row.
1004
                if (! $this->skipValidation && ! $this->validate($row)) {
25✔
1005
                    // Restore $cleanValidationRules
1006
                    $this->cleanValidationRules = $cleanValidationRules;
3✔
1007

1008
                    return false;
3✔
1009
                }
1010

1011
                // Must be called first so we don't
1012
                // strip out created_at values.
1013
                $row = $this->doProtectFieldsForInsert($row);
22✔
1014

1015
                // Set created_at and updated_at with same time
1016
                $date = $this->setDate();
21✔
1017
                $row  = $this->setCreatedField($row, $date);
21✔
1018
                $row  = $this->setUpdatedField($row, $date);
21✔
1019
            }
1020
        }
1021

1022
        // Restore $cleanValidationRules
1023
        $this->cleanValidationRules = $cleanValidationRules;
21✔
1024

1025
        $eventData = ['data' => $set];
21✔
1026

1027
        if ($this->tempAllowCallbacks) {
21✔
1028
            $eventData = $this->trigger('beforeInsertBatch', $eventData);
21✔
1029
        }
1030

1031
        $result = $this->doInsertBatch($eventData['data'], $escape, $batchSize, $testing);
21✔
1032

1033
        $eventData = [
11✔
1034
            'data'   => $eventData['data'],
11✔
1035
            'result' => $result,
11✔
1036
        ];
11✔
1037

1038
        if ($this->tempAllowCallbacks) {
11✔
1039
            // Trigger afterInsert events with the inserted data and new ID
1040
            $this->trigger('afterInsertBatch', $eventData);
11✔
1041
        }
1042

1043
        $this->tempAllowCallbacks = $this->allowCallbacks;
11✔
1044

1045
        return $result;
11✔
1046
    }
1047

1048
    /**
1049
     * Updates a single record in the database. If an object is provided,
1050
     * it will attempt to convert it into an array.
1051
     *
1052
     * @param int|list<int|string>|RawSql|string|null $id
1053
     * @param object|row_array|null                   $row
1054
     *
1055
     * @throws ReflectionException
1056
     */
1057
    public function update($id = null, $row = null): bool
1058
    {
1059
        if ($id !== null) {
62✔
1060
            if (! is_array($id)) {
55✔
1061
                $id = [$id];
47✔
1062
            }
1063

1064
            $this->validateID($id);
55✔
1065
        }
1066

1067
        $row = $this->transformDataToArray($row, 'update');
50✔
1068

1069
        // Validate data before saving.
1070
        if (! $this->skipValidation && ! $this->validate($row)) {
48✔
1071
            return false;
4✔
1072
        }
1073

1074
        // Must be called first, so we don't
1075
        // strip out updated_at values.
1076
        $row = $this->doProtectFieldsForUpdate($row);
44✔
1077

1078
        // doProtectFields() can further remove elements from
1079
        // $row, so we need to check for empty dataset again
1080
        if ($row === []) {
43✔
1081
            throw DataException::forEmptyDataset('update');
2✔
1082
        }
1083

1084
        $row = $this->setUpdatedField($row, $this->setDate());
41✔
1085

1086
        $eventData = [
41✔
1087
            'id'   => $id,
41✔
1088
            'data' => $row,
41✔
1089
        ];
41✔
1090

1091
        if ($this->tempAllowCallbacks) {
41✔
1092
            $eventData = $this->trigger('beforeUpdate', $eventData);
41✔
1093
        }
1094

1095
        $eventData = [
41✔
1096
            'id'     => $id,
41✔
1097
            'data'   => $eventData['data'],
41✔
1098
            'result' => $this->doUpdate($id, $eventData['data']),
41✔
1099
        ];
41✔
1100

1101
        if ($this->tempAllowCallbacks) {
40✔
1102
            $this->trigger('afterUpdate', $eventData);
40✔
1103
        }
1104

1105
        $this->tempAllowCallbacks = $this->allowCallbacks;
40✔
1106

1107
        return $eventData['result'];
40✔
1108
    }
1109

1110
    /**
1111
     * Compiles an update and runs the query.
1112
     *
1113
     * @param list<object|row_array>|null $set       An associative array of insert values.
1114
     * @param string|null                 $index     The where key.
1115
     * @param int                         $batchSize The size of the batch to run.
1116
     * @param bool                        $returnSQL `true` means SQL is returned, `false` will execute the query.
1117
     *
1118
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode.
1119
     *
1120
     * @throws DatabaseException
1121
     * @throws ReflectionException
1122
     */
1123
    public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
1124
    {
1125
        if (is_array($set)) {
9✔
1126
            foreach ($set as &$row) {
9✔
1127
                // Save the index value from the original row because
1128
                // transformDataToArray() may strip it when updateOnlyChanged
1129
                // is true.
1130
                $updateIndex = null;
9✔
1131

1132
                if ($this->updateOnlyChanged) {
9✔
1133
                    if (is_array($row)) {
8✔
1134
                        $updateIndex = $row[$index] ?? null;
7✔
1135
                    } elseif ($row instanceof Entity) {
1✔
1136
                        $updateIndex = $row->toRawArray()[$index] ?? null;
1✔
1137
                    } elseif (is_object($row)) {
×
1138
                        $updateIndex = $row->{$index} ?? null;
×
1139
                    }
1140
                }
1141

1142
                $row = $this->transformDataToArray($row, 'update');
9✔
1143

1144
                // Validate data before saving.
1145
                if (! $this->skipValidation && ! $this->validate($row)) {
9✔
1146
                    return false;
1✔
1147
                }
1148

1149
                // When updateOnlyChanged is true, restore the pre-extracted
1150
                // index into $row. Otherwise read it from the transformed row.
1151
                if ($updateIndex !== null) {
8✔
1152
                    $row[$index] = $updateIndex;
6✔
1153
                } else {
1154
                    $updateIndex = $row[$index] ?? null;
2✔
1155
                }
1156

1157
                if ($updateIndex === null) {
8✔
1158
                    throw new InvalidArgumentException(
1✔
1159
                        'The index ("' . $index . '") for updateBatch() is missing in the data: '
1✔
1160
                        . json_encode($row),
1✔
1161
                    );
1✔
1162
                }
1163

1164
                // Must be called first so we don't
1165
                // strip out updated_at values.
1166
                $this->ensureNoDisallowedFields($row, $index === null ? [] : [$index]);
7✔
1167
                $row = $this->doProtectFields($row);
6✔
1168

1169
                // Restore updateIndex value in case it was wiped out
1170
                $row[$index] = $updateIndex;
6✔
1171

1172
                $row = $this->setUpdatedField($row, $this->setDate());
6✔
1173
            }
1174
        }
1175

1176
        $eventData = ['data' => $set];
6✔
1177

1178
        if ($this->tempAllowCallbacks) {
6✔
1179
            $eventData = $this->trigger('beforeUpdateBatch', $eventData);
6✔
1180
        }
1181

1182
        $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL);
6✔
1183

1184
        $eventData = [
6✔
1185
            'data'   => $eventData['data'],
6✔
1186
            'result' => $result,
6✔
1187
        ];
6✔
1188

1189
        if ($this->tempAllowCallbacks) {
6✔
1190
            // Trigger afterInsert events with the inserted data and new ID
1191
            $this->trigger('afterUpdateBatch', $eventData);
6✔
1192
        }
1193

1194
        $this->tempAllowCallbacks = $this->allowCallbacks;
6✔
1195

1196
        return $result;
6✔
1197
    }
1198

1199
    /**
1200
     * Deletes a single record from the database where $id matches.
1201
     *
1202
     * @param int|list<int|string>|RawSql|string|null $id    The rows primary key(s).
1203
     * @param bool                                    $purge Allows overriding the soft deletes setting.
1204
     *
1205
     * @return bool|string Returns a SQL string if in test mode.
1206
     *
1207
     * @throws DatabaseException
1208
     */
1209
    public function delete($id = null, bool $purge = false)
1210
    {
1211
        if ($id !== null) {
86✔
1212
            if (! is_array($id)) {
72✔
1213
                $id = [$id];
43✔
1214
            }
1215

1216
            $this->validateID($id);
72✔
1217
        }
1218

1219
        $eventData = [
38✔
1220
            'id'    => $id,
38✔
1221
            'purge' => $purge,
38✔
1222
        ];
38✔
1223

1224
        if ($this->tempAllowCallbacks) {
38✔
1225
            $this->trigger('beforeDelete', $eventData);
37✔
1226
        }
1227

1228
        $eventData = [
38✔
1229
            'id'     => $id,
38✔
1230
            'data'   => null,
38✔
1231
            'purge'  => $purge,
38✔
1232
            'result' => $this->doDelete($id, $purge),
38✔
1233
        ];
38✔
1234

1235
        if ($this->tempAllowCallbacks) {
33✔
1236
            $this->trigger('afterDelete', $eventData);
32✔
1237
        }
1238

1239
        $this->tempAllowCallbacks = $this->allowCallbacks;
33✔
1240

1241
        return $eventData['result'];
33✔
1242
    }
1243

1244
    /**
1245
     * Permanently deletes all rows that have been marked as deleted
1246
     * through soft deletes (value of column $deletedField is not null).
1247
     *
1248
     * @return bool|string Returns a SQL string if in test mode.
1249
     */
1250
    public function purgeDeleted()
1251
    {
1252
        if (! $this->useSoftDeletes) {
2✔
1253
            return true;
1✔
1254
        }
1255

1256
        return $this->doPurgeDeleted();
1✔
1257
    }
1258

1259
    /**
1260
     * Sets $useSoftDeletes value so that we can temporarily override
1261
     * the soft deletes settings. Can be used for all find* methods.
1262
     *
1263
     * @return $this
1264
     */
1265
    public function withDeleted(bool $val = true)
1266
    {
1267
        $this->tempUseSoftDeletes = ! $val;
26✔
1268

1269
        return $this;
26✔
1270
    }
1271

1272
    /**
1273
     * Works with the $this->find* methods to return only the rows that
1274
     * have been deleted.
1275
     *
1276
     * @return $this
1277
     */
1278
    public function onlyDeleted()
1279
    {
1280
        $this->tempUseSoftDeletes = false;
1✔
1281
        $this->doOnlyDeleted();
1✔
1282

1283
        return $this;
1✔
1284
    }
1285

1286
    /**
1287
     * Compiles a replace and runs the query.
1288
     *
1289
     * @param row_array|null $row
1290
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query.
1291
     *
1292
     * @return BaseResult|false|Query|string
1293
     */
1294
    public function replace(?array $row = null, bool $returnSQL = false)
1295
    {
1296
        // Validate data before saving.
1297
        if (($row !== null) && ! $this->skipValidation && ! $this->validate($row)) {
3✔
1298
            return false;
1✔
1299
        }
1300

1301
        $row = (array) $row;
2✔
1302
        $row = $this->setCreatedField($row, $this->setDate());
2✔
1303
        $row = $this->setUpdatedField($row, $this->setDate());
2✔
1304

1305
        return $this->doReplace($row, $returnSQL);
2✔
1306
    }
1307

1308
    /**
1309
     * Grabs the last error(s) that occurred.
1310
     *
1311
     * If data was validated, it will first check for errors there,
1312
     *  otherwise will try to grab the last error from the Database connection.
1313
     *
1314
     * The return array should be in the following format:
1315
     *  `['source' => 'message']`.
1316
     *
1317
     * @param bool $forceDB Always grab the DB error, not validation.
1318
     *
1319
     * @return array<string, string>
1320
     */
1321
    public function errors(bool $forceDB = false)
1322
    {
1323
        if ($this->validation === null) {
26✔
1324
            return $this->doErrors();
×
1325
        }
1326

1327
        // Do we have validation errors?
1328
        if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors()) !== []) {
26✔
1329
            return $errors;
24✔
1330
        }
1331

1332
        return $this->doErrors();
2✔
1333
    }
1334

1335
    /**
1336
     * Works with Pager to get the size and offset parameters.
1337
     * Expects a GET variable (?page=2) that specifies the page of results
1338
     * to display.
1339
     *
1340
     * @param int|null $perPage Items per page.
1341
     * @param string   $group   Will be used by the pagination library to identify a unique pagination set.
1342
     * @param int|null $page    Optional page number (useful when the page number is provided in different way).
1343
     * @param int      $segment Optional URI segment number (if page number is provided by URI segment).
1344
     *
1345
     * @return list<object|row_array>
1346
     */
1347
    public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0)
1348
    {
1349
        // Since multiple models may use the Pager, the Pager must be shared.
1350
        $pager = service('pager');
8✔
1351

1352
        if ($segment !== 0) {
8✔
1353
            $pager->setSegment($segment, $group);
×
1354
        }
1355

1356
        $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
8✔
1357
        // Store it in the Pager library, so it can be paginated in the views.
1358
        $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
8✔
1359
        $perPage     = $this->pager->getPerPage($group);
8✔
1360
        $offset      = ($pager->getCurrentPage($group) - 1) * $perPage;
8✔
1361

1362
        return $this->findAll($perPage, $offset);
8✔
1363
    }
1364

1365
    /**
1366
     * It could be used when you have to change default or override current allowed fields.
1367
     *
1368
     * @param list<string> $allowedFields Array with names of fields.
1369
     *
1370
     * @return $this
1371
     */
1372
    public function setAllowedFields(array $allowedFields)
1373
    {
1374
        $this->allowedFields = $allowedFields;
10✔
1375

1376
        return $this;
10✔
1377
    }
1378

1379
    /**
1380
     * Sets whether or not we should whitelist data set during
1381
     * updates or inserts against $this->availableFields.
1382
     *
1383
     * @return $this
1384
     */
1385
    public function protect(bool $protect = true)
1386
    {
1387
        $this->protectFields = $protect;
13✔
1388

1389
        return $this;
13✔
1390
    }
1391

1392
    /**
1393
     * Sets whether or not disallowed fields should throw an exception
1394
     * instead of being discarded.
1395
     *
1396
     * @return $this
1397
     */
1398
    public function throwOnDisallowedFields(bool $throw = true)
1399
    {
1400
        $this->throwOnDisallowedFields = $throw;
11✔
1401

1402
        return $this;
11✔
1403
    }
1404

1405
    /**
1406
     * Ensures that only the fields that are allowed to be updated are
1407
     * in the data array.
1408
     *
1409
     * @used-by update() to protect against mass assignment vulnerabilities.
1410
     * @used-by updateBatch() to protect against mass assignment vulnerabilities.
1411
     *
1412
     * @param row_array $row
1413
     *
1414
     * @return row_array
1415
     *
1416
     * @throws DataException
1417
     */
1418
    protected function doProtectFields(array $row): array
1419
    {
1420
        if (! $this->protectFields) {
49✔
1421
            return $row;
3✔
1422
        }
1423

1424
        if ($this->allowedFields === []) {
46✔
1425
            throw DataException::forInvalidAllowedFields(static::class);
×
1426
        }
1427

1428
        foreach (array_keys($row) as $key) {
46✔
1429
            if (! in_array($key, $this->allowedFields, true)) {
46✔
1430
                unset($row[$key]);
27✔
1431
            }
1432
        }
1433

1434
        return $row;
46✔
1435
    }
1436

1437
    /**
1438
     * Throws when configured to detect fields that would be discarded.
1439
     *
1440
     * @param row_array    $row
1441
     * @param list<string> $ignoredFields
1442
     *
1443
     * @throws DataException
1444
     */
1445
    protected function ensureNoDisallowedFields(array $row, array $ignoredFields = []): void
1446
    {
1447
        if (! $this->throwOnDisallowedFields || ! $this->protectFields || $this->allowedFields === []) {
147✔
1448
            return;
138✔
1449
        }
1450

1451
        $disallowedFields = [];
9✔
1452

1453
        foreach (array_keys($row) as $key) {
9✔
1454
            if (in_array($key, $this->allowedFields, true) || in_array($key, $ignoredFields, true)) {
9✔
1455
                continue;
9✔
1456
            }
1457

1458
            $disallowedFields[] = $key;
5✔
1459
        }
1460

1461
        if ($disallowedFields !== []) {
9✔
1462
            throw DataException::forDisallowedFields(static::class, $disallowedFields);
5✔
1463
        }
1464
    }
1465

1466
    /**
1467
     * Ensures that only the fields that are allowed to be inserted are in
1468
     * the data array.
1469
     *
1470
     * @used-by insert() to protect against mass assignment vulnerabilities.
1471
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1472
     *
1473
     * @param row_array $row
1474
     *
1475
     * @return row_array
1476
     *
1477
     * @throws DataException
1478
     */
1479
    protected function doProtectFieldsForInsert(array $row): array
1480
    {
NEW
1481
        $this->ensureNoDisallowedFields($row);
×
1482

NEW
1483
        return $this->doProtectFields($row);
×
1484
    }
1485

1486
    /**
1487
     * Ensures that only the fields that are allowed to be updated are in
1488
     * the data array.
1489
     *
1490
     * @used-by update() to protect against mass assignment vulnerabilities.
1491
     *
1492
     * @param row_array $row
1493
     *
1494
     * @return row_array
1495
     *
1496
     * @throws DataException
1497
     */
1498
    protected function doProtectFieldsForUpdate(array $row): array
1499
    {
NEW
1500
        $this->ensureNoDisallowedFields($row);
×
1501

UNCOV
1502
        return $this->doProtectFields($row);
×
1503
    }
1504

1505
    /**
1506
     * Sets the timestamp or current timestamp if null value is passed.
1507
     *
1508
     * @param int|null $userDate An optional PHP timestamp to be converted
1509
     *
1510
     * @return int|string
1511
     *
1512
     * @throws ModelException
1513
     */
1514
    protected function setDate(?int $userDate = null)
1515
    {
1516
        $currentDate = $userDate ?? Time::now()->getTimestamp();
173✔
1517

1518
        return $this->intToDate($currentDate);
173✔
1519
    }
1520

1521
    /**
1522
     * A utility function to allow child models to use the type of
1523
     * date/time format that they prefer. This is primarily used for
1524
     * setting created_at, updated_at and deleted_at values, but can be
1525
     * used by inheriting classes.
1526
     *
1527
     * The available time formats are:
1528
     *  - 'int'      - Stores the date as an integer timestamp.
1529
     *  - 'datetime' - Stores the data in the SQL datetime format.
1530
     *  - 'date'     - Stores the date (only) in the SQL date format.
1531
     *
1532
     * @return int|string
1533
     *
1534
     * @throws ModelException
1535
     */
1536
    protected function intToDate(int $value)
1537
    {
1538
        return match ($this->dateFormat) {
173✔
1539
            'int'      => $value,
36✔
1540
            'datetime' => date($this->db->dateFormat['datetime'], $value),
135✔
1541
            'date'     => date($this->db->dateFormat['date'], $value),
1✔
1542
            default    => throw ModelException::forNoDateFormat(static::class),
173✔
1543
        };
173✔
1544
    }
1545

1546
    /**
1547
     * Converts Time value to string using $this->dateFormat.
1548
     *
1549
     * The available time formats are:
1550
     *  - 'int'      - Stores the date as an integer timestamp.
1551
     *  - 'datetime' - Stores the data in the SQL datetime format.
1552
     *  - 'date'     - Stores the date (only) in the SQL date format.
1553
     *
1554
     * @return int|string
1555
     */
1556
    protected function timeToDate(Time $value)
1557
    {
1558
        return match ($this->dateFormat) {
5✔
1559
            'datetime' => $value->format($this->db->dateFormat['datetime']),
3✔
1560
            'date'     => $value->format($this->db->dateFormat['date']),
1✔
1561
            'int'      => $value->getTimestamp(),
1✔
1562
            default    => (string) $value,
5✔
1563
        };
5✔
1564
    }
1565

1566
    /**
1567
     * Set the value of the $skipValidation flag.
1568
     *
1569
     * @return $this
1570
     */
1571
    public function skipValidation(bool $skip = true)
1572
    {
1573
        $this->skipValidation = $skip;
2✔
1574

1575
        return $this;
2✔
1576
    }
1577

1578
    /**
1579
     * Allows to set (and reset) validation messages.
1580
     * It could be used when you have to change default or override current validate messages.
1581
     *
1582
     * @param array<string, array<string, string>> $validationMessages
1583
     *
1584
     * @return $this
1585
     */
1586
    public function setValidationMessages(array $validationMessages)
1587
    {
1588
        $this->validationMessages = $validationMessages;
×
1589

1590
        return $this;
×
1591
    }
1592

1593
    /**
1594
     * Allows to set field wise validation message.
1595
     * It could be used when you have to change default or override current validate messages.
1596
     *
1597
     * @param array<string, string> $fieldMessages
1598
     *
1599
     * @return $this
1600
     */
1601
    public function setValidationMessage(string $field, array $fieldMessages)
1602
    {
1603
        $this->validationMessages[$field] = $fieldMessages;
2✔
1604

1605
        return $this;
2✔
1606
    }
1607

1608
    /**
1609
     * Allows to set (and reset) validation rules.
1610
     * It could be used when you have to change default or override current validate rules.
1611
     *
1612
     * @param array<string, array<string, array<string, string>|string>|string> $validationRules
1613
     *
1614
     * @return $this
1615
     */
1616
    public function setValidationRules(array $validationRules)
1617
    {
1618
        $this->validationRules = $validationRules;
2✔
1619

1620
        return $this;
2✔
1621
    }
1622

1623
    /**
1624
     * Allows to set field wise validation rules.
1625
     * It could be used when you have to change default or override current validate rules.
1626
     *
1627
     * @param array<string, array<string, string>|string>|string $fieldRules
1628
     *
1629
     * @return $this
1630
     */
1631
    public function setValidationRule(string $field, $fieldRules)
1632
    {
1633
        $rules = $this->validationRules;
2✔
1634

1635
        // ValidationRules can be either a string, which is the group name,
1636
        // or an array of rules.
1637
        if (is_string($rules)) {
2✔
1638
            $this->ensureValidation();
1✔
1639

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

1642
            $this->validationRules = $rules;
1✔
1643
            $this->validationMessages += $customErrors;
1✔
1644
        }
1645

1646
        $this->validationRules[$field] = $fieldRules;
2✔
1647

1648
        return $this;
2✔
1649
    }
1650

1651
    /**
1652
     * Should validation rules be removed before saving?
1653
     * Most handy when doing updates.
1654
     *
1655
     * @return $this
1656
     */
1657
    public function cleanRules(bool $choice = false)
1658
    {
1659
        $this->cleanValidationRules = $choice;
2✔
1660

1661
        return $this;
2✔
1662
    }
1663

1664
    /**
1665
     * Validate the row data against the validation rules (or the validation group)
1666
     * specified in the class property, $validationRules.
1667
     *
1668
     * @param object|row_array $row
1669
     */
1670
    public function validate($row): bool
1671
    {
1672
        if ($this->skipValidation) {
193✔
1673
            return true;
×
1674
        }
1675

1676
        $rules = $this->getValidationRules();
193✔
1677

1678
        if ($rules === []) {
193✔
1679
            return true;
145✔
1680
        }
1681

1682
        // Validation requires array, so cast away.
1683
        if (is_object($row)) {
48✔
1684
            $row = (array) $row;
2✔
1685
        }
1686

1687
        if ($row === []) {
48✔
1688
            return true;
×
1689
        }
1690

1691
        $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules;
48✔
1692

1693
        // If no data existed that needs validation
1694
        // our job is done here.
1695
        if ($rules === []) {
48✔
1696
            return true;
2✔
1697
        }
1698

1699
        $this->ensureValidation();
46✔
1700

1701
        $this->validation->reset()->setRules($rules, $this->validationMessages);
46✔
1702

1703
        return $this->validation->run($row, null, $this->DBGroup);
46✔
1704
    }
1705

1706
    /**
1707
     * Returns the model's defined validation rules so that they
1708
     * can be used elsewhere, if needed.
1709
     *
1710
     * @param array{only?: list<string>, except?: list<string>} $options Filter the list of rules
1711
     *
1712
     * @return array<string, array<string, array<string, string>|string>|string>
1713
     */
1714
    public function getValidationRules(array $options = []): array
1715
    {
1716
        $rules = $this->validationRules;
195✔
1717

1718
        // ValidationRules can be either a string, which is the group name,
1719
        // or an array of rules.
1720
        if (is_string($rules)) {
195✔
1721
            $this->ensureValidation();
13✔
1722

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

1725
            $this->validationMessages += $customErrors;
13✔
1726
        }
1727

1728
        if (isset($options['except'])) {
195✔
1729
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1730
        } elseif (isset($options['only'])) {
195✔
1731
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1732
        }
1733

1734
        return $rules;
195✔
1735
    }
1736

1737
    protected function ensureValidation(): void
1738
    {
1739
        if ($this->validation === null) {
47✔
1740
            $this->validation = service('validation', null, false);
31✔
1741
        }
1742
    }
1743

1744
    /**
1745
     * Returns the model's validation messages, so they
1746
     * can be used elsewhere, if needed.
1747
     *
1748
     * @return array<string, array<string, string>>
1749
     */
1750
    public function getValidationMessages(): array
1751
    {
1752
        return $this->validationMessages;
2✔
1753
    }
1754

1755
    /**
1756
     * Removes any rules that apply to fields that have not been set
1757
     * currently so that rules don't block updating when only updating
1758
     * a partial row.
1759
     *
1760
     * @param array<string, array<string, array<string, string>|string>|string> $rules
1761
     * @param row_array                                                         $row
1762
     *
1763
     * @return array<string, array<string, array<string, string>|string>|string>
1764
     */
1765
    protected function cleanValidationRules(array $rules, array $row): array
1766
    {
1767
        if ($row === []) {
21✔
1768
            return [];
2✔
1769
        }
1770

1771
        foreach (array_keys($rules) as $field) {
19✔
1772
            if (! array_key_exists($field, $row)) {
19✔
1773
                unset($rules[$field]);
8✔
1774
            }
1775
        }
1776

1777
        return $rules;
19✔
1778
    }
1779

1780
    /**
1781
     * Sets $tempAllowCallbacks value so that we can temporarily override
1782
     * the setting. Resets after the next method that uses triggers.
1783
     *
1784
     * @return $this
1785
     */
1786
    public function allowCallbacks(bool $val = true)
1787
    {
1788
        $this->tempAllowCallbacks = $val;
3✔
1789

1790
        return $this;
3✔
1791
    }
1792

1793
    /**
1794
     * A simple event trigger for Model Events that allows additional
1795
     * data manipulation within the model. Specifically intended for
1796
     * usage by child models this can be used to format data,
1797
     * save/load related classes, etc.
1798
     *
1799
     * It is the responsibility of the callback methods to return
1800
     * the data itself.
1801
     *
1802
     * Each $eventData array MUST have a 'data' key with the relevant
1803
     * data for callback methods (like an array of key/value pairs to insert
1804
     * or update, an array of results, etc.)
1805
     *
1806
     * If callbacks are not allowed then returns $eventData immediately.
1807
     *
1808
     * @template TEventData of array<string, mixed>
1809
     *
1810
     * @param string     $event     Valid property of the model event: $this->before*, $this->after*, etc.
1811
     * @param TEventData $eventData
1812
     *
1813
     * @return TEventData
1814
     *
1815
     * @throws DataException
1816
     */
1817
    protected function trigger(string $event, array $eventData)
1818
    {
1819
        // Ensure it's a valid event
1820
        if (! isset($this->{$event}) || $this->{$event} === []) {
231✔
1821
            return $eventData;
216✔
1822
        }
1823

1824
        foreach ($this->{$event} as $callback) {
15✔
1825
            if (! method_exists($this, $callback)) {
15✔
1826
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1827
            }
1828

1829
            $eventData = $this->{$callback}($eventData);
14✔
1830
        }
1831

1832
        return $eventData;
14✔
1833
    }
1834

1835
    /**
1836
     * Sets the return type of the results to be as an associative array.
1837
     *
1838
     * @return $this
1839
     */
1840
    public function asArray()
1841
    {
1842
        $this->tempReturnType = 'array';
31✔
1843

1844
        return $this;
31✔
1845
    }
1846

1847
    /**
1848
     * Sets the return type to be of the specified type of object.
1849
     * Defaults to a simple object, but can be any class that has
1850
     * class vars with the same name as the collection columns,
1851
     * or at least allows them to be created.
1852
     *
1853
     * @param 'object'|class-string $class
1854
     *
1855
     * @return $this
1856
     */
1857
    public function asObject(string $class = 'object')
1858
    {
1859
        $this->tempReturnType = $class;
18✔
1860

1861
        return $this;
18✔
1862
    }
1863

1864
    /**
1865
     * Takes a class and returns an array of its public and protected
1866
     * properties as an array suitable for use in creates and updates.
1867
     * This method uses `$this->objectToRawArray()` internally and does conversion
1868
     * to string on all Time instances.
1869
     *
1870
     * @param object $object
1871
     * @param bool   $onlyChanged Returns only the changed properties.
1872
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1873
     *
1874
     * @return array<string, mixed>
1875
     *
1876
     * @throws ReflectionException
1877
     */
1878
    protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
1879
    {
1880
        $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
25✔
1881

1882
        // Convert any Time instances to appropriate $dateFormat
1883
        return $this->timeToString($properties);
25✔
1884
    }
1885

1886
    /**
1887
     * Convert any Time instances to appropriate $dateFormat.
1888
     *
1889
     * @param array<string, mixed> $properties
1890
     *
1891
     * @return array<string, mixed>
1892
     */
1893
    protected function timeToString(array $properties): array
1894
    {
1895
        if ($properties === []) {
185✔
1896
            return [];
1✔
1897
        }
1898

1899
        return array_map(function ($value) {
184✔
1900
            if ($value instanceof Time) {
184✔
1901
                return $this->timeToDate($value);
5✔
1902
            }
1903

1904
            return $value;
184✔
1905
        }, $properties);
184✔
1906
    }
1907

1908
    /**
1909
     * Takes a class and returns an array of its public and protected
1910
     * properties as an array with raw values.
1911
     *
1912
     * @param object $object
1913
     * @param bool   $onlyChanged Returns only the changed properties.
1914
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1915
     *
1916
     * @return array<string, mixed> Array with raw values
1917
     *
1918
     * @throws ReflectionException
1919
     */
1920
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
1921
    {
1922
        // Entity::toRawArray() returns array
1923
        if (method_exists($object, 'toRawArray')) {
25✔
1924
            $properties = $object->toRawArray($onlyChanged, $recursive);
23✔
1925
        } else {
1926
            $mirror = new ReflectionClass($object);
2✔
1927
            $props  = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
2✔
1928

1929
            $properties = [];
2✔
1930

1931
            // Loop over each property,
1932
            // saving the name/value in a new array we can return
1933
            foreach ($props as $prop) {
2✔
1934
                $properties[$prop->getName()] = $prop->getValue($object);
2✔
1935
            }
1936
        }
1937

1938
        return $properties;
25✔
1939
    }
1940

1941
    /**
1942
     * Transform data to array.
1943
     *
1944
     * @param object|row_array|null $row
1945
     *
1946
     * @return array<int|string, mixed>
1947
     *
1948
     * @throws DataException
1949
     * @throws InvalidArgumentException
1950
     * @throws ReflectionException
1951
     *
1952
     * @used-by insert()
1953
     * @used-by insertBatch()
1954
     * @used-by update()
1955
     * @used-by updateBatch()
1956
     */
1957
    protected function transformDataToArray($row, string $type): array
1958
    {
1959
        if (! in_array($type, ['insert', 'update'], true)) {
189✔
1960
            throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
1✔
1961
        }
1962

1963
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
188✔
1964
            throw DataException::forEmptyDataset($type);
6✔
1965
        }
1966

1967
        // If it validates with entire rules, all fields are needed.
1968
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
185✔
1969
            $onlyChanged = false;
157✔
1970
        } else {
1971
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
59✔
1972
        }
1973

1974
        if ($this->useCasts()) {
185✔
1975
            if (is_array($row)) {
27✔
1976
                $row = $this->converter->toDataSource($row);
27✔
1977
            } elseif ($row instanceof stdClass) {
7✔
1978
                $row = (array) $row;
3✔
1979
                $row = $this->converter->toDataSource($row);
3✔
1980
            } elseif ($row instanceof Entity) {
4✔
1981
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1982
            } elseif (is_object($row)) {
2✔
1983
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1984
            }
1985
        }
1986
        // If $row is using a custom class with public or protected
1987
        // properties representing the collection elements, we need to grab
1988
        // them as an array.
1989
        elseif (is_object($row) && ! $row instanceof stdClass) {
158✔
1990
            $row = $this->objectToArray($row, $onlyChanged, true);
24✔
1991
        }
1992

1993
        // If it's still a stdClass, go ahead and convert to
1994
        // an array so doProtectFields and other model methods
1995
        // don't have to do special checks.
1996
        if (is_object($row)) {
185✔
1997
            $row = (array) $row;
16✔
1998
        }
1999

2000
        // If it's still empty here, means $row is no change or is empty object
2001
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
185✔
2002
            throw DataException::forEmptyDataset($type);
×
2003
        }
2004

2005
        // Convert any Time instances to appropriate $dateFormat
2006
        return $this->timeToString($row);
185✔
2007
    }
2008

2009
    /**
2010
     * Provides the DB connection and model's properties.
2011
     *
2012
     * @return mixed
2013
     */
2014
    public function __get(string $name)
2015
    {
2016
        if (property_exists($this, $name)) {
50✔
2017
            return $this->{$name};
50✔
2018
        }
2019

2020
        return $this->db->{$name} ?? null;
1✔
2021
    }
2022

2023
    /**
2024
     * Checks for the existence of properties across this model, and DB connection.
2025
     */
2026
    public function __isset(string $name): bool
2027
    {
2028
        if (property_exists($this, $name)) {
50✔
2029
            return true;
50✔
2030
        }
2031

2032
        return isset($this->db->{$name});
1✔
2033
    }
2034

2035
    /**
2036
     * Provides direct access to method in the database connection.
2037
     *
2038
     * @param array<int|string, mixed> $params
2039
     *
2040
     * @return mixed
2041
     */
2042
    public function __call(string $name, array $params)
2043
    {
2044
        if (method_exists($this->db, $name)) {
×
2045
            return $this->db->{$name}(...$params);
×
2046
        }
2047

2048
        return null;
×
2049
    }
2050

2051
    /**
2052
     * Sets $allowEmptyInserts.
2053
     */
2054
    public function allowEmptyInserts(bool $value = true): self
2055
    {
2056
        $this->allowEmptyInserts = $value;
1✔
2057

2058
        return $this;
1✔
2059
    }
2060

2061
    /**
2062
     * Converts database data array to return type value.
2063
     *
2064
     * @param array<string, mixed>          $row        Raw data from database.
2065
     * @param 'array'|'object'|class-string $returnType
2066
     *
2067
     * @return array<string, mixed>|object
2068
     */
2069
    protected function convertToReturnType(array $row, string $returnType): array|object
2070
    {
2071
        if ($returnType === 'array') {
25✔
2072
            return $this->converter->fromDataSource($row);
10✔
2073
        }
2074

2075
        if ($returnType === 'object') {
17✔
2076
            return (object) $this->converter->fromDataSource($row);
5✔
2077
        }
2078

2079
        return $this->converter->reconstruct($returnType, $row);
12✔
2080
    }
2081
}
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