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

codeigniter4 / CodeIgniter4 / 28354285098

29 Jun 2026 06:57AM UTC coverage: 89.115% (-0.003%) from 89.118%
28354285098

push

github

web-flow
feat: add strict field protection for Models (#10302)

* feat(model): add strict field protection

Add an opt-in Model setting that throws when write data contains fields
that would otherwise be discarded by allowed field protection.

- Add $strictFieldProtection and strictFieldProtection()
- Throw DataException for disallowed write fields in strict mode
- Preserve existing primary key and updateBatch index behavior
- Cover insert, save, update, batch, validation order, and protect(false)
- Document the new option and update the model generator template

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>

* test: avoid SQLSRV identity update in strict protection test

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>

* refactor: rename disallowed field exception mode

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>

* fix: restore validation state on insert batch exception

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>

---------

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>

28 of 31 new or added lines in 4 files covered. (90.32%)

1 existing line in 1 file now uncovered.

25027 of 28084 relevant lines covered (89.11%)

225.52 hits per line

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

95.11
/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;
433✔
383
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
433✔
384
        $this->tempAllowCallbacks = $this->allowCallbacks;
433✔
385

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

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

392
    /**
393
     * Creates DataConverter instance.
394
     */
395
    protected function createDataConverter(): void
396
    {
397
        if ($this->useCasts()) {
433✔
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 !== [];
433✔
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
    }
432✔
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;
26✔
997
        $this->cleanValidationRules = false;
26✔
998

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

1004
                    // Validate every row.
1005
                    if (! $this->skipValidation && ! $this->validate($row)) {
26✔
1006
                        return false;
3✔
1007
                    }
1008

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

1013
                    // Set created_at and updated_at with same time
1014
                    $date = $this->setDate();
21✔
1015
                    $row  = $this->setCreatedField($row, $date);
21✔
1016
                    $row  = $this->setUpdatedField($row, $date);
21✔
1017
                }
1018
            }
1019
        } finally {
1020
            // Restore $cleanValidationRules
1021
            $this->cleanValidationRules = $cleanValidationRules;
26✔
1022
        }
1023

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

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

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

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

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

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

1044
        return $result;
11✔
1045
    }
1046

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1195
        return $result;
6✔
1196
    }
1197

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

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

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

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

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

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

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

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

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

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

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

1268
        return $this;
26✔
1269
    }
1270

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

1282
        return $this;
1✔
1283
    }
1284

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

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

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

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

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

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

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

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

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

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

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

1375
        return $this;
10✔
1376
    }
1377

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

1388
        return $this;
13✔
1389
    }
1390

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

1401
        return $this;
12✔
1402
    }
1403

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

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

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

1433
        return $row;
46✔
1434
    }
1435

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

1450
        $disallowedFields = [];
10✔
1451

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

1457
            $disallowedFields[] = $key;
6✔
1458
        }
1459

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

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

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

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

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

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

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

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

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

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

1574
        return $this;
2✔
1575
    }
1576

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

1589
        return $this;
×
1590
    }
1591

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

1604
        return $this;
2✔
1605
    }
1606

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

1619
        return $this;
2✔
1620
    }
1621

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

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

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

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

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

1647
        return $this;
2✔
1648
    }
1649

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

1660
        return $this;
2✔
1661
    }
1662

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

1675
        $rules = $this->getValidationRules();
194✔
1676

1677
        if ($rules === []) {
194✔
1678
            return true;
146✔
1679
        }
1680

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

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

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

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

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

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

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

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

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

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

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

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

1733
        return $rules;
196✔
1734
    }
1735

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

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

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

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

1776
        return $rules;
19✔
1777
    }
1778

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

1789
        return $this;
3✔
1790
    }
1791

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

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

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

1831
        return $eventData;
14✔
1832
    }
1833

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

1843
        return $this;
31✔
1844
    }
1845

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

1860
        return $this;
18✔
1861
    }
1862

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

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

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

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

1903
            return $value;
185✔
1904
        }, $properties);
185✔
1905
    }
1906

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

1928
            $properties = [];
3✔
1929

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

1937
        return $properties;
29✔
1938
    }
1939

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

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

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

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

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

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

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

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

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

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

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

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

2047
        return null;
×
2048
    }
2049

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

2057
        return $this;
1✔
2058
    }
2059

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

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

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

© 2026 Coveralls, Inc