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

codeigniter4 / CodeIgniter4 / 27373991620

11 Jun 2026 08:03PM UTC coverage: 88.991%. Remained the same
27373991620

Pull #10302

github

web-flow
Merge 8cb893f46 into f88932939
Pull Request #10302: feat: add strict field protection for Models

17 of 19 new or added lines in 4 files covered. (89.47%)

1 existing line in 1 file now uncovered.

24784 of 27850 relevant lines covered (88.99%)

225.37 hits per line

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

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

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

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

392
    /**
393
     * Creates DataConverter instance.
394
     */
395
    protected function createDataConverter(): void
396
    {
397
        if ($this->useCasts()) {
431✔
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 !== [];
431✔
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
    }
430✔
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)) {
148✔
975
            $row[$this->updatedField] = $date;
43✔
976
        }
977

978
        return $row;
148✔
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) {
61✔
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');
49✔
1068

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

1074
        // Must be called first, so we don't
1075
        // strip out updated_at values.
1076
        $this->ensureNoDisallowedFields($row, $this->getFieldProtectionIgnoredFieldsForUpdate($id));
43✔
1077
        $row = $this->doProtectFields($row);
42✔
1078

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

1085
        $row = $this->setUpdatedField($row, $this->setDate());
40✔
1086

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

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

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

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

1106
        $this->tempAllowCallbacks = $this->allowCallbacks;
39✔
1107

1108
        return $eventData['result'];
39✔
1109
    }
1110

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1197
        return $result;
6✔
1198
    }
1199

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

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

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

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

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

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

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

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

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

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

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

1270
        return $this;
26✔
1271
    }
1272

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

1284
        return $this;
1✔
1285
    }
1286

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

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

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

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

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

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

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

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

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

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

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

1377
        return $this;
10✔
1378
    }
1379

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

1390
        return $this;
13✔
1391
    }
1392

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

1403
        return $this;
10✔
1404
    }
1405

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

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

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

1435
        return $row;
45✔
1436
    }
1437

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

1452
        $disallowedFields = [];
8✔
1453

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

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

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

1467
    /**
1468
     * Returns fields that may be used by the write operation but not persisted.
1469
     *
1470
     * @param mixed $id
1471
     *
1472
     * @return list<string>
1473
     */
1474
    protected function getFieldProtectionIgnoredFieldsForUpdate($id): array
1475
    {
NEW
1476
        return [];
×
1477
    }
1478

1479
    /**
1480
     * Ensures that only the fields that are allowed to be inserted are in
1481
     * the data array.
1482
     *
1483
     * @used-by insert() to protect against mass assignment vulnerabilities.
1484
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1485
     *
1486
     * @param row_array $row
1487
     *
1488
     * @return row_array
1489
     *
1490
     * @throws DataException
1491
     */
1492
    protected function doProtectFieldsForInsert(array $row): array
1493
    {
NEW
1494
        $this->ensureNoDisallowedFields($row);
×
1495

UNCOV
1496
        return $this->doProtectFields($row);
×
1497
    }
1498

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

1512
        return $this->intToDate($currentDate);
172✔
1513
    }
1514

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

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

1560
    /**
1561
     * Set the value of the $skipValidation flag.
1562
     *
1563
     * @return $this
1564
     */
1565
    public function skipValidation(bool $skip = true)
1566
    {
1567
        $this->skipValidation = $skip;
2✔
1568

1569
        return $this;
2✔
1570
    }
1571

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

1584
        return $this;
×
1585
    }
1586

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

1599
        return $this;
2✔
1600
    }
1601

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

1614
        return $this;
2✔
1615
    }
1616

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

1629
        // ValidationRules can be either a string, which is the group name,
1630
        // or an array of rules.
1631
        if (is_string($rules)) {
2✔
1632
            $this->ensureValidation();
1✔
1633

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

1636
            $this->validationRules = $rules;
1✔
1637
            $this->validationMessages += $customErrors;
1✔
1638
        }
1639

1640
        $this->validationRules[$field] = $fieldRules;
2✔
1641

1642
        return $this;
2✔
1643
    }
1644

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

1655
        return $this;
2✔
1656
    }
1657

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

1670
        $rules = $this->getValidationRules();
192✔
1671

1672
        if ($rules === []) {
192✔
1673
            return true;
144✔
1674
        }
1675

1676
        // Validation requires array, so cast away.
1677
        if (is_object($row)) {
48✔
1678
            $row = (array) $row;
2✔
1679
        }
1680

1681
        if ($row === []) {
48✔
1682
            return true;
×
1683
        }
1684

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

1687
        // If no data existed that needs validation
1688
        // our job is done here.
1689
        if ($rules === []) {
48✔
1690
            return true;
2✔
1691
        }
1692

1693
        $this->ensureValidation();
46✔
1694

1695
        $this->validation->reset()->setRules($rules, $this->validationMessages);
46✔
1696

1697
        return $this->validation->run($row, null, $this->DBGroup);
46✔
1698
    }
1699

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

1712
        // ValidationRules can be either a string, which is the group name,
1713
        // or an array of rules.
1714
        if (is_string($rules)) {
194✔
1715
            $this->ensureValidation();
13✔
1716

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

1719
            $this->validationMessages += $customErrors;
13✔
1720
        }
1721

1722
        if (isset($options['except'])) {
194✔
1723
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1724
        } elseif (isset($options['only'])) {
194✔
1725
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1726
        }
1727

1728
        return $rules;
194✔
1729
    }
1730

1731
    protected function ensureValidation(): void
1732
    {
1733
        if ($this->validation === null) {
47✔
1734
            $this->validation = service('validation', null, false);
31✔
1735
        }
1736
    }
1737

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

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

1765
        foreach (array_keys($rules) as $field) {
19✔
1766
            if (! array_key_exists($field, $row)) {
19✔
1767
                unset($rules[$field]);
8✔
1768
            }
1769
        }
1770

1771
        return $rules;
19✔
1772
    }
1773

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

1784
        return $this;
3✔
1785
    }
1786

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

1818
        foreach ($this->{$event} as $callback) {
15✔
1819
            if (! method_exists($this, $callback)) {
15✔
1820
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1821
            }
1822

1823
            $eventData = $this->{$callback}($eventData);
14✔
1824
        }
1825

1826
        return $eventData;
14✔
1827
    }
1828

1829
    /**
1830
     * Sets the return type of the results to be as an associative array.
1831
     *
1832
     * @return $this
1833
     */
1834
    public function asArray()
1835
    {
1836
        $this->tempReturnType = 'array';
31✔
1837

1838
        return $this;
31✔
1839
    }
1840

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

1855
        return $this;
18✔
1856
    }
1857

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

1876
        // Convert any Time instances to appropriate $dateFormat
1877
        return $this->timeToString($properties);
25✔
1878
    }
1879

1880
    /**
1881
     * Convert any Time instances to appropriate $dateFormat.
1882
     *
1883
     * @param array<string, mixed> $properties
1884
     *
1885
     * @return array<string, mixed>
1886
     */
1887
    protected function timeToString(array $properties): array
1888
    {
1889
        if ($properties === []) {
184✔
1890
            return [];
1✔
1891
        }
1892

1893
        return array_map(function ($value) {
183✔
1894
            if ($value instanceof Time) {
183✔
1895
                return $this->timeToDate($value);
5✔
1896
            }
1897

1898
            return $value;
183✔
1899
        }, $properties);
183✔
1900
    }
1901

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

1923
            $properties = [];
2✔
1924

1925
            // Loop over each property,
1926
            // saving the name/value in a new array we can return
1927
            foreach ($props as $prop) {
2✔
1928
                $properties[$prop->getName()] = $prop->getValue($object);
2✔
1929
            }
1930
        }
1931

1932
        return $properties;
25✔
1933
    }
1934

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

1957
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
187✔
1958
            throw DataException::forEmptyDataset($type);
6✔
1959
        }
1960

1961
        // If it validates with entire rules, all fields are needed.
1962
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
184✔
1963
            $onlyChanged = false;
157✔
1964
        } else {
1965
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
58✔
1966
        }
1967

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

1987
        // If it's still a stdClass, go ahead and convert to
1988
        // an array so doProtectFields and other model methods
1989
        // don't have to do special checks.
1990
        if (is_object($row)) {
184✔
1991
            $row = (array) $row;
16✔
1992
        }
1993

1994
        // If it's still empty here, means $row is no change or is empty object
1995
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
184✔
1996
            throw DataException::forEmptyDataset($type);
×
1997
        }
1998

1999
        // Convert any Time instances to appropriate $dateFormat
2000
        return $this->timeToString($row);
184✔
2001
    }
2002

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

2014
        return $this->db->{$name} ?? null;
1✔
2015
    }
2016

2017
    /**
2018
     * Checks for the existence of properties across this model, and DB connection.
2019
     */
2020
    public function __isset(string $name): bool
2021
    {
2022
        if (property_exists($this, $name)) {
50✔
2023
            return true;
50✔
2024
        }
2025

2026
        return isset($this->db->{$name});
1✔
2027
    }
2028

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

2042
        return null;
×
2043
    }
2044

2045
    /**
2046
     * Sets $allowEmptyInserts.
2047
     */
2048
    public function allowEmptyInserts(bool $value = true): self
2049
    {
2050
        $this->allowEmptyInserts = $value;
1✔
2051

2052
        return $this;
1✔
2053
    }
2054

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

2069
        if ($returnType === 'object') {
17✔
2070
            return (object) $this->converter->fromDataSource($row);
5✔
2071
        }
2072

2073
        return $this->converter->reconstruct($returnType, $row);
12✔
2074
    }
2075
}
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