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

codeigniter4 / CodeIgniter4 / 28845769237

07 Jul 2026 06:15AM UTC coverage: 89.212% (+0.004%) from 89.208%
28845769237

push

github

web-flow
feat: add insert-only fields to Model (#10350)

* feat(model): add insert-only fields

- add $insertOnlyFields and setInsertOnlyFields() to Model
- prevent configured fields from being submitted during update operations
- allow insert-only fields during insert and insertBatch
- document the Model-level protection boundaries
- add coverage for update, save, updateBatch, entity, and protect(false) paths

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

* trigger CI

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

* refactor: align insert-only fields with field protection

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

* test: update generated model expectations

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

---------

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

20 of 21 new or added lines in 4 files covered. (95.24%)

1 existing line in 1 file now uncovered.

25181 of 28226 relevant lines covered (89.21%)

228.37 hits per line

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

95.07
/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<array-key, mixed>
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
     * Fields that may be inserted but not updated through Model write methods.
160
     *
161
     * @var list<string>
162
     */
163
    protected array $insertOnlyFields = [];
164

165
    /**
166
     * If true, will set created_at, and updated_at
167
     * values during insert and update routines.
168
     *
169
     * @var bool
170
     */
171
    protected $useTimestamps = false;
172

173
    /**
174
     * The type of column that created_at and updated_at
175
     * are expected to.
176
     *
177
     * @var 'date'|'datetime'|'int'
178
     */
179
    protected $dateFormat = 'datetime';
180

181
    /**
182
     * The column used for insert timestamps.
183
     *
184
     * @var string
185
     */
186
    protected $createdField = 'created_at';
187

188
    /**
189
     * The column used for update timestamps.
190
     *
191
     * @var string
192
     */
193
    protected $updatedField = 'updated_at';
194

195
    /**
196
     * If this model should use "softDeletes" and
197
     * simply set a date when rows are deleted, or
198
     * do hard deletes.
199
     *
200
     * @var bool
201
     */
202
    protected $useSoftDeletes = false;
203

204
    /**
205
     * Used by $this->withDeleted() to override the
206
     * model's "softDelete" setting.
207
     *
208
     * @var bool
209
     */
210
    protected $tempUseSoftDeletes;
211

212
    /**
213
     * The column used to save soft delete state.
214
     *
215
     * @var string
216
     */
217
    protected $deletedField = 'deleted_at';
218

219
    /**
220
     * Whether to allow inserting empty data.
221
     */
222
    protected bool $allowEmptyInserts = false;
223

224
    /**
225
     * Whether to update Entity's only changed data.
226
     */
227
    protected bool $updateOnlyChanged = true;
228

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

242
    /**
243
     * Contains any custom error messages to be
244
     * used during data validation.
245
     *
246
     * @var array<string, array<string, string>> The column is used as the keys.
247
     */
248
    protected $validationMessages = [];
249

250
    /**
251
     * Skip the model's validation.
252
     *
253
     * Used in conjunction with `$this->skipValidation()`
254
     * to skip data validation for any future calls.
255
     *
256
     * @var bool
257
     */
258
    protected $skipValidation = false;
259

260
    /**
261
     * Whether rules should be removed that do not exist
262
     * in the passed data. Used in updates.
263
     *
264
     * @var bool
265
     */
266
    protected $cleanValidationRules = true;
267

268
    /**
269
     * Our validator instance.
270
     *
271
     * @var ValidationInterface|null
272
     */
273
    protected $validation;
274

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

288
    /**
289
     * Whether to trigger the defined callbacks.
290
     *
291
     * @var bool
292
     */
293
    protected $allowCallbacks = true;
294

295
    /**
296
     * Used by $this->allowCallbacks() to override the
297
     * model's $allowCallbacks setting.
298
     *
299
     * @var bool
300
     */
301
    protected $tempAllowCallbacks;
302

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

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

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

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

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

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

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

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

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

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

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

380
    /**
381
     * Callbacks for "afterDelete" event.
382
     *
383
     * @var list<string>
384
     */
385
    protected $afterDelete = [];
386

387
    public function __construct(?ValidationInterface $validation = null)
388
    {
389
        $this->tempReturnType     = $this->returnType;
448✔
390
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
448✔
391
        $this->tempAllowCallbacks = $this->allowCallbacks;
448✔
392

393
        $this->validation = $validation;
448✔
394

395
        $this->initialize();
448✔
396
        $this->createDataConverter();
448✔
397
    }
398

399
    /**
400
     * Creates DataConverter instance.
401
     */
402
    protected function createDataConverter(): void
403
    {
404
        if ($this->useCasts()) {
448✔
405
            $this->converter = new DataConverter(
30✔
406
                $this->casts,
30✔
407
                $this->castHandlers,
30✔
408
                $this->db,
30✔
409
            );
30✔
410
        }
411
    }
412

413
    /**
414
     * Are casts used?
415
     */
416
    protected function useCasts(): bool
417
    {
418
        return $this->casts !== [];
448✔
419
    }
420

421
    /**
422
     * Initializes the instance with any additional steps.
423
     * Optionally implemented by child classes.
424
     *
425
     * @return void
426
     */
427
    protected function initialize()
428
    {
429
    }
447✔
430

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

443
    /**
444
     * Fetches the column of database.
445
     * This method works only with DB calls.
446
     *
447
     * @return list<row_array>|null The resulting row of data or `null` if no data found.
448
     *
449
     * @throws DataException
450
     */
451
    abstract protected function doFindColumn(string $columnName);
452

453
    /**
454
     * Fetches all results, while optionally limiting them.
455
     * This method works only with DB calls.
456
     *
457
     * @return list<object|row_array>
458
     */
459
    abstract protected function doFindAll(?int $limit = null, int $offset = 0);
460

461
    /**
462
     * Returns the first row of the result set.
463
     * This method works only with DB calls.
464
     *
465
     * @return object|row_array|null
466
     */
467
    abstract protected function doFirst();
468

469
    /**
470
     * Inserts data into the current database.
471
     * This method works only with DB calls.
472
     *
473
     * @param row_array $row
474
     *
475
     * @return bool
476
     */
477
    abstract protected function doInsert(array $row);
478

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

492
    /**
493
     * Updates a single record in the database.
494
     * This method works only with DB calls.
495
     *
496
     * @param int|list<int|string>|string|null $id
497
     * @param row_array|null                   $row
498
     */
499
    abstract protected function doUpdate($id = null, $row = null): bool;
500

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

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

530
    /**
531
     * Permanently deletes all rows that have been marked as deleted
532
     * through soft deletes (value of column $deletedField is not null).
533
     * This method works only with DB calls.
534
     *
535
     * @return bool|string Returns a SQL string if in test mode.
536
     */
537
    abstract protected function doPurgeDeleted();
538

539
    /**
540
     * Works with the $this->find* methods to return only the rows that
541
     * have been deleted (value of column $deletedField is not null).
542
     * This method works only with DB calls.
543
     *
544
     * @return void
545
     */
546
    abstract protected function doOnlyDeleted();
547

548
    /**
549
     * Compiles a replace and runs the query.
550
     * This method works only with DB calls.
551
     *
552
     * @param row_array|null $row
553
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query.
554
     *
555
     * @return BaseResult|false|Query|string
556
     */
557
    abstract protected function doReplace(?array $row = null, bool $returnSQL = false);
558

559
    /**
560
     * Grabs the last error(s) that occurred from the Database connection.
561
     * This method works only with DB calls.
562
     *
563
     * @return array<string, string>
564
     */
565
    abstract protected function doErrors();
566

567
    /**
568
     * Public getter to return the ID value for the data array or object.
569
     * For example with SQL this will return `$data->{$this->primaryKey}`.
570
     *
571
     * @param object|row_array $row
572
     *
573
     * @return int|string|null
574
     */
575
    abstract public function getIdValue($row);
576

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

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

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

619
    /**
620
     * Fetches the row of database.
621
     *
622
     * @param int|list<int|string>|string|null $id One primary key or an array of primary keys.
623
     *
624
     * @return ($id is int|string ? object|row_array|null :  list<object|row_array>)
625
     */
626
    public function find($id = null)
627
    {
628
        $singleton = is_numeric($id) || is_string($id);
60✔
629

630
        if ($this->tempAllowCallbacks) {
60✔
631
            // Call the before event and check for a return
632
            $eventData = $this->trigger('beforeFind', [
57✔
633
                'id'        => $id,
57✔
634
                'method'    => 'find',
57✔
635
                'singleton' => $singleton,
57✔
636
            ]);
57✔
637

638
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
57✔
639
                return $eventData['data'];
3✔
640
            }
641
        }
642

643
        $eventData = [
58✔
644
            'id'        => $id,
58✔
645
            'data'      => $this->doFind($singleton, $id),
58✔
646
            'method'    => 'find',
58✔
647
            'singleton' => $singleton,
58✔
648
        ];
58✔
649

650
        if ($this->tempAllowCallbacks) {
57✔
651
            $eventData = $this->trigger('afterFind', $eventData);
54✔
652
        }
653

654
        $this->tempReturnType     = $this->returnType;
57✔
655
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
57✔
656
        $this->tempAllowCallbacks = $this->allowCallbacks;
57✔
657

658
        return $eventData['data'];
57✔
659
    }
660

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

674
        $resultSet = $this->doFindColumn($columnName);
2✔
675

676
        return $resultSet !== null ? array_column($resultSet, $columnName) : null;
2✔
677
    }
678

679
    /**
680
     * Fetches all results, while optionally limiting them.
681
     *
682
     * @return list<object|row_array>
683
     */
684
    public function findAll(?int $limit = null, int $offset = 0)
685
    {
686
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
22✔
687
        if ($limitZeroAsAll) {
22✔
688
            $limit ??= 0;
22✔
689
        }
690

691
        if ($this->tempAllowCallbacks) {
22✔
692
            // Call the before event and check for a return
693
            $eventData = $this->trigger('beforeFind', [
22✔
694
                'method'    => 'findAll',
22✔
695
                'limit'     => $limit,
22✔
696
                'offset'    => $offset,
22✔
697
                'singleton' => false,
22✔
698
            ]);
22✔
699

700
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
22✔
701
                return $eventData['data'];
1✔
702
            }
703
        }
704

705
        $eventData = [
22✔
706
            'data'      => $this->doFindAll($limit, $offset),
22✔
707
            'limit'     => $limit,
22✔
708
            'offset'    => $offset,
22✔
709
            'method'    => 'findAll',
22✔
710
            'singleton' => false,
22✔
711
        ];
22✔
712

713
        if ($this->tempAllowCallbacks) {
22✔
714
            $eventData = $this->trigger('afterFind', $eventData);
22✔
715
        }
716

717
        $this->tempReturnType     = $this->returnType;
22✔
718
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
22✔
719
        $this->tempAllowCallbacks = $this->allowCallbacks;
22✔
720

721
        return $eventData['data'];
22✔
722
    }
723

724
    /**
725
     * Returns the first row of the result set.
726
     *
727
     * @return object|row_array|null
728
     */
729
    public function first()
730
    {
731
        if ($this->tempAllowCallbacks) {
36✔
732
            // Call the before event and check for a return
733
            $eventData = $this->trigger('beforeFind', [
36✔
734
                'method'    => 'first',
36✔
735
                'singleton' => true,
36✔
736
            ]);
36✔
737

738
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
36✔
739
                return $eventData['data'];
1✔
740
            }
741
        }
742

743
        $eventData = [
36✔
744
            'data'      => $this->doFirst(),
36✔
745
            'method'    => 'first',
36✔
746
            'singleton' => true,
36✔
747
        ];
36✔
748

749
        if ($this->tempAllowCallbacks) {
36✔
750
            $eventData = $this->trigger('afterFind', $eventData);
36✔
751
        }
752

753
        $this->tempReturnType     = $this->returnType;
36✔
754
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
36✔
755
        $this->tempAllowCallbacks = $this->allowCallbacks;
36✔
756

757
        return $eventData['data'];
36✔
758
    }
759

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

779
        if ($this->shouldUpdate($row)) {
30✔
780
            $response = $this->update($this->getIdValue($row), $row);
19✔
781
        } else {
782
            $response = $this->insert($row, false);
15✔
783

784
            if ($response !== false) {
13✔
785
                $response = true;
12✔
786
            }
787
        }
788

789
        return $response;
27✔
790
    }
791

792
    /**
793
     * This method is called on save to determine if entry have to be updated.
794
     * If this method returns `false` insert operation will be executed.
795
     *
796
     * @param object|row_array $row
797
     */
798
    protected function shouldUpdate($row): bool
799
    {
800
        $id = $this->getIdValue($row);
30✔
801

802
        return ! in_array($id, [null, [], ''], true);
30✔
803
    }
804

805
    /**
806
     * Returns last insert ID or 0.
807
     *
808
     * @return int|string
809
     */
810
    public function getInsertID()
811
    {
812
        return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID;
11✔
813
    }
814

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

834
            // Check for empty array
835
            if ($id === []) {
135✔
836
                throw new InvalidArgumentException('Invalid primary key: cannot be an empty array.');
5✔
837
            }
838

839
            // Validate each ID in the array recursively
840
            foreach ($id as $key => $valueId) {
130✔
841
                if (is_array($valueId)) {
130✔
842
                    throw new InvalidArgumentException(
5✔
843
                        sprintf('Invalid primary key at index %s: nested arrays are not allowed.', $key),
5✔
844
                    );
5✔
845
                }
846

847
                // Recursive call for each value (single values only in recursion)
848
                $this->validateID($valueId, false);
125✔
849
            }
850

851
            return;
75✔
852
        }
853

854
        // Allow RawSql objects for complex scenarios
855
        if ($id instanceof RawSql) {
143✔
856
            return;
2✔
857
        }
858

859
        // Check for invalid single values
860
        if (in_array($id, [null, 0, '0', '', true, false], true)) {
141✔
861
            $type = is_bool($id) ? 'boolean ' . var_export($id, true) : var_export($id, true);
60✔
862

863
            throw new InvalidArgumentException(
60✔
864
                sprintf('Invalid primary key: %s is not allowed.', $type),
60✔
865
            );
60✔
866
        }
867

868
        // Only allow int and string at this point
869
        if (! is_int($id) && ! is_string($id)) {
111✔
870
            throw new InvalidArgumentException(
×
871
                sprintf('Invalid primary key: must be int or string, %s given.', get_debug_type($id)),
×
872
            );
×
873
        }
874
    }
875

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

892
        // Set $cleanValidationRules to false temporary.
893
        $cleanValidationRules       = $this->cleanValidationRules;
133✔
894
        $this->cleanValidationRules = false;
133✔
895

896
        $row = $this->transformDataToArray($row, 'insert');
133✔
897

898
        // Validate data before saving.
899
        if (! $this->skipValidation && ! $this->validate($row)) {
131✔
900
            // Restore $cleanValidationRules
901
            $this->cleanValidationRules = $cleanValidationRules;
21✔
902

903
            return false;
21✔
904
        }
905

906
        // Restore $cleanValidationRules
907
        $this->cleanValidationRules = $cleanValidationRules;
112✔
908

909
        // Must be called first, so we don't
910
        // strip out created_at values.
911
        $row = $this->doProtectFieldsForInsert($row);
112✔
912

913
        // doProtectFields() can further remove elements from
914
        // $row, so we need to check for empty dataset again
915
        if (! $this->allowEmptyInserts && $row === []) {
109✔
916
            throw DataException::forEmptyDataset('insert');
2✔
917
        }
918

919
        // Set created_at and updated_at with same time
920
        $date = $this->setDate();
107✔
921
        $row  = $this->setCreatedField($row, $date);
107✔
922
        $row  = $this->setUpdatedField($row, $date);
107✔
923

924
        $eventData = ['data' => $row];
107✔
925

926
        if ($this->tempAllowCallbacks) {
107✔
927
            $eventData = $this->trigger('beforeInsert', $eventData);
107✔
928
        }
929

930
        $result = $this->doInsert($eventData['data']);
106✔
931

932
        $eventData = [
94✔
933
            'id'     => $this->insertID,
94✔
934
            'data'   => $eventData['data'],
94✔
935
            'result' => $result,
94✔
936
        ];
94✔
937

938
        if ($this->tempAllowCallbacks) {
94✔
939
            // Trigger afterInsert events with the inserted data and new ID
940
            $this->trigger('afterInsert', $eventData);
94✔
941
        }
942

943
        $this->tempAllowCallbacks = $this->allowCallbacks;
94✔
944

945
        // If insertion failed, get out of here
946
        if (! $result) {
94✔
947
            return $result;
4✔
948
        }
949

950
        // otherwise return the insertID, if requested.
951
        return $returnID ? $this->insertID : $result;
90✔
952
    }
953

954
    /**
955
     * Set datetime to created field.
956
     *
957
     * @param row_array  $row
958
     * @param int|string $date Timestamp or datetime string.
959
     *
960
     * @return row_array
961
     */
962
    protected function setCreatedField(array $row, $date): array
963
    {
964
        if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) {
130✔
965
            $row[$this->createdField] = $date;
39✔
966
        }
967

968
        return $row;
130✔
969
    }
970

971
    /**
972
     * Set datetime to updated field.
973
     *
974
     * @param row_array  $row
975
     * @param int|string $date Timestamp or datetime string
976
     *
977
     * @return row_array
978
     */
979
    protected function setUpdatedField(array $row, $date): array
980
    {
981
        if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) {
159✔
982
            $row[$this->updatedField] = $date;
43✔
983
        }
984

985
        return $row;
159✔
986
    }
987

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

1006
        try {
1007
            if (is_array($set)) {
27✔
1008
                foreach ($set as &$row) {
27✔
1009
                    $row = $this->transformDataToArray($row, 'insert');
27✔
1010

1011
                    // Validate every row.
1012
                    if (! $this->skipValidation && ! $this->validate($row)) {
27✔
1013
                        return false;
3✔
1014
                    }
1015

1016
                    // Must be called first so we don't
1017
                    // strip out created_at values.
1018
                    $row = $this->doProtectFieldsForInsert($row);
24✔
1019

1020
                    // Set created_at and updated_at with same time
1021
                    $date = $this->setDate();
22✔
1022
                    $row  = $this->setCreatedField($row, $date);
22✔
1023
                    $row  = $this->setUpdatedField($row, $date);
22✔
1024
                }
1025
            }
1026
        } finally {
1027
            // Restore $cleanValidationRules
1028
            $this->cleanValidationRules = $cleanValidationRules;
27✔
1029
        }
1030

1031
        $eventData = ['data' => $set];
22✔
1032

1033
        if ($this->tempAllowCallbacks) {
22✔
1034
            $eventData = $this->trigger('beforeInsertBatch', $eventData);
22✔
1035
        }
1036

1037
        $result = $this->doInsertBatch($eventData['data'], $escape, $batchSize, $testing);
22✔
1038

1039
        $eventData = [
12✔
1040
            'data'   => $eventData['data'],
12✔
1041
            'result' => $result,
12✔
1042
        ];
12✔
1043

1044
        if ($this->tempAllowCallbacks) {
12✔
1045
            // Trigger afterInsert events with the inserted data and new ID
1046
            $this->trigger('afterInsertBatch', $eventData);
12✔
1047
        }
1048

1049
        $this->tempAllowCallbacks = $this->allowCallbacks;
12✔
1050

1051
        return $result;
12✔
1052
    }
1053

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

1070
            $this->validateID($id);
63✔
1071
        }
1072

1073
        $row = $this->transformDataToArray($row, 'update');
60✔
1074

1075
        // Validate data before saving.
1076
        if (! $this->skipValidation && ! $this->validate($row)) {
58✔
1077
            return false;
5✔
1078
        }
1079

1080
        // Must be called first, so we don't
1081
        // strip out updated_at values.
1082
        $row = $this->doProtectFieldsForUpdate($row);
53✔
1083

1084
        // doProtectFields() can further remove elements from
1085
        // $row, so we need to check for empty dataset again
1086
        if ($row === []) {
49✔
1087
            throw DataException::forEmptyDataset('update');
2✔
1088
        }
1089

1090
        $row = $this->setUpdatedField($row, $this->setDate());
47✔
1091

1092
        $eventData = [
47✔
1093
            'id'   => $id,
47✔
1094
            'data' => $row,
47✔
1095
        ];
47✔
1096

1097
        if ($this->tempAllowCallbacks) {
47✔
1098
            $eventData = $this->trigger('beforeUpdate', $eventData);
47✔
1099
        }
1100

1101
        $eventData = [
47✔
1102
            'id'     => $id,
47✔
1103
            'data'   => $eventData['data'],
47✔
1104
            'result' => $this->doUpdate($id, $eventData['data']),
47✔
1105
        ];
47✔
1106

1107
        if ($this->tempAllowCallbacks) {
46✔
1108
            $this->trigger('afterUpdate', $eventData);
46✔
1109
        }
1110

1111
        $this->tempAllowCallbacks = $this->allowCallbacks;
46✔
1112

1113
        return $eventData['result'];
46✔
1114
    }
1115

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

1138
                if ($this->updateOnlyChanged) {
12✔
1139
                    if (is_array($row)) {
11✔
1140
                        $updateIndex = $row[$index] ?? null;
10✔
1141
                    } elseif ($row instanceof Entity) {
1✔
1142
                        $updateIndex = $row->toRawArray()[$index] ?? null;
1✔
1143
                    } elseif (is_object($row)) {
×
1144
                        $updateIndex = $row->{$index} ?? null;
×
1145
                    }
1146
                }
1147

1148
                $row = $this->transformDataToArray($row, 'update');
12✔
1149

1150
                // Validate data before saving.
1151
                if (! $this->skipValidation && ! $this->validate($row)) {
12✔
1152
                    return false;
1✔
1153
                }
1154

1155
                // When updateOnlyChanged is true, restore the pre-extracted
1156
                // index into $row. Otherwise read it from the transformed row.
1157
                if ($updateIndex !== null) {
11✔
1158
                    $row[$index] = $updateIndex;
9✔
1159
                } else {
1160
                    $updateIndex = $row[$index] ?? null;
2✔
1161
                }
1162

1163
                if ($updateIndex === null) {
11✔
1164
                    throw new InvalidArgumentException(
1✔
1165
                        'The index ("' . $index . '") for updateBatch() is missing in the data: '
1✔
1166
                        . json_encode($row),
1✔
1167
                    );
1✔
1168
                }
1169

1170
                // Must be called first so we don't
1171
                // strip out updated_at values.
1172
                $ignoredFields = $index === null ? [] : [$index];
10✔
1173

1174
                $row = $this->doProtectInsertOnlyFieldsForUpdate($row, $ignoredFields);
10✔
1175
                $this->ensureNoDisallowedFields($row, $ignoredFields);
9✔
1176
                $row = $this->doProtectFields($row);
8✔
1177

1178
                // Restore updateIndex value in case it was wiped out
1179
                $row[$index] = $updateIndex;
8✔
1180

1181
                $row = $this->setUpdatedField($row, $this->setDate());
8✔
1182
            }
1183
        }
1184

1185
        $eventData = ['data' => $set];
8✔
1186

1187
        if ($this->tempAllowCallbacks) {
8✔
1188
            $eventData = $this->trigger('beforeUpdateBatch', $eventData);
8✔
1189
        }
1190

1191
        $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL);
8✔
1192

1193
        $eventData = [
8✔
1194
            'data'   => $eventData['data'],
8✔
1195
            'result' => $result,
8✔
1196
        ];
8✔
1197

1198
        if ($this->tempAllowCallbacks) {
8✔
1199
            // Trigger afterInsert events with the inserted data and new ID
1200
            $this->trigger('afterUpdateBatch', $eventData);
8✔
1201
        }
1202

1203
        $this->tempAllowCallbacks = $this->allowCallbacks;
8✔
1204

1205
        return $result;
8✔
1206
    }
1207

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

1225
            $this->validateID($id);
72✔
1226
        }
1227

1228
        $eventData = [
38✔
1229
            'id'    => $id,
38✔
1230
            'purge' => $purge,
38✔
1231
        ];
38✔
1232

1233
        if ($this->tempAllowCallbacks) {
38✔
1234
            $this->trigger('beforeDelete', $eventData);
37✔
1235
        }
1236

1237
        $eventData = [
38✔
1238
            'id'     => $id,
38✔
1239
            'data'   => null,
38✔
1240
            'purge'  => $purge,
38✔
1241
            'result' => $this->doDelete($id, $purge),
38✔
1242
        ];
38✔
1243

1244
        if ($this->tempAllowCallbacks) {
33✔
1245
            $this->trigger('afterDelete', $eventData);
32✔
1246
        }
1247

1248
        $this->tempAllowCallbacks = $this->allowCallbacks;
33✔
1249

1250
        return $eventData['result'];
33✔
1251
    }
1252

1253
    /**
1254
     * Permanently deletes all rows that have been marked as deleted
1255
     * through soft deletes (value of column $deletedField is not null).
1256
     *
1257
     * @return bool|string Returns a SQL string if in test mode.
1258
     */
1259
    public function purgeDeleted()
1260
    {
1261
        if (! $this->useSoftDeletes) {
2✔
1262
            return true;
1✔
1263
        }
1264

1265
        return $this->doPurgeDeleted();
1✔
1266
    }
1267

1268
    /**
1269
     * Sets $useSoftDeletes value so that we can temporarily override
1270
     * the soft deletes settings. Can be used for all find* methods.
1271
     *
1272
     * @return $this
1273
     */
1274
    public function withDeleted(bool $val = true)
1275
    {
1276
        $this->tempUseSoftDeletes = ! $val;
26✔
1277

1278
        return $this;
26✔
1279
    }
1280

1281
    /**
1282
     * Works with the $this->find* methods to return only the rows that
1283
     * have been deleted.
1284
     *
1285
     * @return $this
1286
     */
1287
    public function onlyDeleted()
1288
    {
1289
        $this->tempUseSoftDeletes = false;
1✔
1290
        $this->doOnlyDeleted();
1✔
1291

1292
        return $this;
1✔
1293
    }
1294

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

1310
        $row = (array) $row;
2✔
1311
        $row = $this->setCreatedField($row, $this->setDate());
2✔
1312
        $row = $this->setUpdatedField($row, $this->setDate());
2✔
1313

1314
        return $this->doReplace($row, $returnSQL);
2✔
1315
    }
1316

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

1336
        // Do we have validation errors?
1337
        if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors()) !== []) {
27✔
1338
            return $errors;
25✔
1339
        }
1340

1341
        return $this->doErrors();
2✔
1342
    }
1343

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

1361
        if ($segment !== 0) {
8✔
1362
            $pager->setSegment($segment, $group);
×
1363
        }
1364

1365
        $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
8✔
1366
        // Store it in the Pager library, so it can be paginated in the views.
1367
        $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
8✔
1368
        $perPage     = $this->pager->getPerPage($group);
8✔
1369
        $offset      = ($pager->getCurrentPage($group) - 1) * $perPage;
8✔
1370

1371
        return $this->findAll($perPage, $offset);
8✔
1372
    }
1373

1374
    /**
1375
     * It could be used when you have to change default or override current allowed fields.
1376
     *
1377
     * @param list<string> $allowedFields Array with names of fields.
1378
     *
1379
     * @return $this
1380
     */
1381
    public function setAllowedFields(array $allowedFields)
1382
    {
1383
        $this->allowedFields = $allowedFields;
10✔
1384

1385
        return $this;
10✔
1386
    }
1387

1388
    /**
1389
     * Sets the fields that may be inserted but not updated.
1390
     *
1391
     * @param list<string> $insertOnlyFields
1392
     *
1393
     * @return $this
1394
     */
1395
    public function setInsertOnlyFields(array $insertOnlyFields)
1396
    {
1397
        $this->insertOnlyFields = $insertOnlyFields;
13✔
1398

1399
        return $this;
13✔
1400
    }
1401

1402
    /**
1403
     * Sets whether or not we should whitelist data set during
1404
     * updates or inserts against $this->availableFields.
1405
     *
1406
     * @return $this
1407
     */
1408
    public function protect(bool $protect = true)
1409
    {
1410
        $this->protectFields = $protect;
14✔
1411

1412
        return $this;
14✔
1413
    }
1414

1415
    /**
1416
     * Sets whether or not disallowed fields should throw an exception
1417
     * instead of being discarded.
1418
     *
1419
     * @return $this
1420
     */
1421
    public function throwOnDisallowedFields(bool $throw = true)
1422
    {
1423
        $this->throwOnDisallowedFields = $throw;
16✔
1424

1425
        return $this;
16✔
1426
    }
1427

1428
    /**
1429
     * Ensures that only the fields that are allowed to be updated are
1430
     * in the data array.
1431
     *
1432
     * @used-by update() to protect against mass assignment vulnerabilities.
1433
     * @used-by updateBatch() to protect against mass assignment vulnerabilities.
1434
     *
1435
     * @param row_array $row
1436
     *
1437
     * @return row_array
1438
     *
1439
     * @throws DataException
1440
     */
1441
    protected function doProtectFields(array $row): array
1442
    {
1443
        if (! $this->protectFields) {
57✔
1444
            return $row;
4✔
1445
        }
1446

1447
        if ($this->allowedFields === []) {
53✔
1448
            throw DataException::forInvalidAllowedFields(static::class);
×
1449
        }
1450

1451
        foreach (array_keys($row) as $key) {
53✔
1452
            if (! in_array($key, $this->allowedFields, true)) {
53✔
1453
                unset($row[$key]);
29✔
1454
            }
1455
        }
1456

1457
        return $row;
53✔
1458
    }
1459

1460
    /**
1461
     * Throws when configured to detect fields that would be discarded.
1462
     *
1463
     * @param row_array    $row
1464
     * @param list<string> $ignoredFields
1465
     *
1466
     * @throws DataException
1467
     */
1468
    protected function ensureNoDisallowedFields(array $row, array $ignoredFields = []): void
1469
    {
1470
        if (! $this->throwOnDisallowedFields || ! $this->protectFields || $this->allowedFields === []) {
158✔
1471
            return;
148✔
1472
        }
1473

1474
        $disallowedFields = [];
10✔
1475

1476
        foreach (array_keys($row) as $key) {
10✔
1477
            if (in_array($key, $this->allowedFields, true) || in_array($key, $ignoredFields, true)) {
10✔
1478
                continue;
10✔
1479
            }
1480

1481
            $disallowedFields[] = $key;
6✔
1482
        }
1483

1484
        if ($disallowedFields !== []) {
10✔
1485
            throw DataException::forDisallowedFields(static::class, $disallowedFields);
6✔
1486
        }
1487
    }
1488

1489
    /**
1490
     * Removes fields from update data when they may only be inserted.
1491
     *
1492
     * @param row_array    $row
1493
     * @param list<string> $ignoredFields
1494
     *
1495
     * @return row_array
1496
     *
1497
     * @throws DataException
1498
     */
1499
    protected function doProtectInsertOnlyFieldsForUpdate(array $row, array $ignoredFields = []): array
1500
    {
1501
        if (! $this->protectFields || $this->allowedFields === [] || $this->insertOnlyFields === []) {
63✔
1502
            return $row;
52✔
1503
        }
1504

1505
        $insertOnlyFields = [];
11✔
1506

1507
        foreach (array_keys($row) as $key) {
11✔
1508
            if (in_array($key, $ignoredFields, true)) {
11✔
1509
                continue;
5✔
1510
            }
1511

1512
            if (in_array($key, $this->insertOnlyFields, true)) {
11✔
1513
                $insertOnlyFields[] = $key;
9✔
1514
                unset($row[$key]);
9✔
1515
            }
1516
        }
1517

1518
        if ($insertOnlyFields !== [] && $this->throwOnDisallowedFields) {
11✔
1519
            throw DataException::forInsertOnlyFields(static::class, $insertOnlyFields);
4✔
1520
        }
1521

1522
        return $row;
7✔
1523
    }
1524

1525
    /**
1526
     * Ensures that only the fields that are allowed to be inserted are in
1527
     * the data array.
1528
     *
1529
     * @used-by insert() to protect against mass assignment vulnerabilities.
1530
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1531
     *
1532
     * @param row_array $row
1533
     *
1534
     * @return row_array
1535
     *
1536
     * @throws DataException
1537
     */
1538
    protected function doProtectFieldsForInsert(array $row): array
1539
    {
1540
        $this->ensureNoDisallowedFields($row);
×
1541

1542
        return $this->doProtectFields($row);
×
1543
    }
1544

1545
    /**
1546
     * Ensures that only the fields that are allowed to be updated are in
1547
     * the data array.
1548
     *
1549
     * @used-by update() to protect against mass assignment vulnerabilities.
1550
     *
1551
     * @param row_array $row
1552
     *
1553
     * @return row_array
1554
     *
1555
     * @throws DataException
1556
     */
1557
    protected function doProtectFieldsForUpdate(array $row): array
1558
    {
NEW
1559
        $row = $this->doProtectInsertOnlyFieldsForUpdate($row);
×
UNCOV
1560
        $this->ensureNoDisallowedFields($row);
×
1561

1562
        return $this->doProtectFields($row);
×
1563
    }
1564

1565
    /**
1566
     * Sets the timestamp or current timestamp if null value is passed.
1567
     *
1568
     * @param int|null $userDate An optional PHP timestamp to be converted
1569
     *
1570
     * @return int|string
1571
     *
1572
     * @throws ModelException
1573
     */
1574
    protected function setDate(?int $userDate = null)
1575
    {
1576
        $currentDate = $userDate ?? Time::now()->getTimestamp();
183✔
1577

1578
        return $this->intToDate($currentDate);
183✔
1579
    }
1580

1581
    /**
1582
     * A utility function to allow child models to use the type of
1583
     * date/time format that they prefer. This is primarily used for
1584
     * setting created_at, updated_at and deleted_at values, but can be
1585
     * used by inheriting classes.
1586
     *
1587
     * The available time formats are:
1588
     *  - 'int'      - Stores the date as an integer timestamp.
1589
     *  - 'datetime' - Stores the data in the SQL datetime format.
1590
     *  - 'date'     - Stores the date (only) in the SQL date format.
1591
     *
1592
     * @return int|string
1593
     *
1594
     * @throws ModelException
1595
     */
1596
    protected function intToDate(int $value)
1597
    {
1598
        return match ($this->dateFormat) {
183✔
1599
            'int'      => $value,
36✔
1600
            'datetime' => date($this->db->dateFormat['datetime'], $value),
145✔
1601
            'date'     => date($this->db->dateFormat['date'], $value),
1✔
1602
            default    => throw ModelException::forNoDateFormat(static::class),
183✔
1603
        };
183✔
1604
    }
1605

1606
    /**
1607
     * Converts Time value to string using $this->dateFormat.
1608
     *
1609
     * The available time formats are:
1610
     *  - 'int'      - Stores the date as an integer timestamp.
1611
     *  - 'datetime' - Stores the data in the SQL datetime format.
1612
     *  - 'date'     - Stores the date (only) in the SQL date format.
1613
     *
1614
     * @return int|string
1615
     */
1616
    protected function timeToDate(Time $value)
1617
    {
1618
        return match ($this->dateFormat) {
5✔
1619
            'datetime' => $value->format($this->db->dateFormat['datetime']),
3✔
1620
            'date'     => $value->format($this->db->dateFormat['date']),
1✔
1621
            'int'      => $value->getTimestamp(),
1✔
1622
            default    => (string) $value,
5✔
1623
        };
5✔
1624
    }
1625

1626
    /**
1627
     * Set the value of the $skipValidation flag.
1628
     *
1629
     * @return $this
1630
     */
1631
    public function skipValidation(bool $skip = true)
1632
    {
1633
        $this->skipValidation = $skip;
2✔
1634

1635
        return $this;
2✔
1636
    }
1637

1638
    /**
1639
     * Allows to set (and reset) validation messages.
1640
     * It could be used when you have to change default or override current validate messages.
1641
     *
1642
     * @param array<string, array<string, string>> $validationMessages
1643
     *
1644
     * @return $this
1645
     */
1646
    public function setValidationMessages(array $validationMessages)
1647
    {
1648
        $this->validationMessages = $validationMessages;
×
1649

1650
        return $this;
×
1651
    }
1652

1653
    /**
1654
     * Allows to set field wise validation message.
1655
     * It could be used when you have to change default or override current validate messages.
1656
     *
1657
     * @param array<string, string> $fieldMessages
1658
     *
1659
     * @return $this
1660
     */
1661
    public function setValidationMessage(string $field, array $fieldMessages)
1662
    {
1663
        $this->validationMessages[$field] = $fieldMessages;
2✔
1664

1665
        return $this;
2✔
1666
    }
1667

1668
    /**
1669
     * Allows to set (and reset) validation rules.
1670
     * It could be used when you have to change default or override current validate rules.
1671
     *
1672
     * @param array<string, array<string, array<string, string>|string>|string> $validationRules
1673
     *
1674
     * @return $this
1675
     */
1676
    public function setValidationRules(array $validationRules)
1677
    {
1678
        $this->validationRules = $validationRules;
2✔
1679

1680
        return $this;
2✔
1681
    }
1682

1683
    /**
1684
     * Allows to set field wise validation rules.
1685
     * It could be used when you have to change default or override current validate rules.
1686
     *
1687
     * @param array<string, array<string, string>|string>|string $fieldRules
1688
     *
1689
     * @return $this
1690
     */
1691
    public function setValidationRule(string $field, $fieldRules)
1692
    {
1693
        $rules = $this->validationRules;
2✔
1694

1695
        // ValidationRules can be either a string, which is the group name,
1696
        // or an array of rules.
1697
        if (is_string($rules)) {
2✔
1698
            $this->ensureValidation();
1✔
1699

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

1702
            $this->validationRules = $rules;
1✔
1703
            $this->validationMessages += $customErrors;
1✔
1704
        }
1705

1706
        $this->validationRules[$field] = $fieldRules;
2✔
1707

1708
        return $this;
2✔
1709
    }
1710

1711
    /**
1712
     * Should validation rules be removed before saving?
1713
     * Most handy when doing updates.
1714
     *
1715
     * @return $this
1716
     */
1717
    public function cleanRules(bool $choice = false)
1718
    {
1719
        $this->cleanValidationRules = $choice;
2✔
1720

1721
        return $this;
2✔
1722
    }
1723

1724
    /**
1725
     * Validate the row data against the validation rules (or the validation group)
1726
     * specified in the class property, $validationRules.
1727
     *
1728
     * @param object|row_array $row
1729
     */
1730
    public function validate($row): bool
1731
    {
1732
        if ($this->skipValidation) {
209✔
1733
            return true;
×
1734
        }
1735

1736
        $rules = $this->getValidationRules();
209✔
1737

1738
        if ($rules === []) {
209✔
1739
            return true;
160✔
1740
        }
1741

1742
        // Validation requires array, so cast away.
1743
        if (is_object($row)) {
49✔
1744
            $row = (array) $row;
2✔
1745
        }
1746

1747
        if ($row === []) {
49✔
1748
            return true;
×
1749
        }
1750

1751
        $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules;
49✔
1752

1753
        // If no data existed that needs validation
1754
        // our job is done here.
1755
        if ($rules === []) {
49✔
1756
            return true;
2✔
1757
        }
1758

1759
        $this->ensureValidation();
47✔
1760

1761
        $this->validation->reset()->setRules($rules, $this->validationMessages);
47✔
1762

1763
        return $this->validation->run($row, null, $this->DBGroup);
47✔
1764
    }
1765

1766
    /**
1767
     * Returns the model's defined validation rules so that they
1768
     * can be used elsewhere, if needed.
1769
     *
1770
     * @param array{only?: list<string>, except?: list<string>} $options Filter the list of rules
1771
     *
1772
     * @return array<string, array<string, array<string, string>|string>|string>
1773
     */
1774
    public function getValidationRules(array $options = []): array
1775
    {
1776
        $rules = $this->validationRules;
211✔
1777

1778
        // ValidationRules can be either a string, which is the group name,
1779
        // or an array of rules.
1780
        if (is_string($rules)) {
211✔
1781
            $this->ensureValidation();
13✔
1782

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

1785
            $this->validationMessages += $customErrors;
13✔
1786
        }
1787

1788
        if (isset($options['except'])) {
211✔
1789
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1790
        } elseif (isset($options['only'])) {
211✔
1791
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1792
        }
1793

1794
        return $rules;
211✔
1795
    }
1796

1797
    protected function ensureValidation(): void
1798
    {
1799
        if ($this->validation === null) {
48✔
1800
            $this->validation = service('validation', null, false);
32✔
1801
        }
1802
    }
1803

1804
    /**
1805
     * Returns the model's validation messages, so they
1806
     * can be used elsewhere, if needed.
1807
     *
1808
     * @return array<string, array<string, string>>
1809
     */
1810
    public function getValidationMessages(): array
1811
    {
1812
        return $this->validationMessages;
2✔
1813
    }
1814

1815
    /**
1816
     * Removes any rules that apply to fields that have not been set
1817
     * currently so that rules don't block updating when only updating
1818
     * a partial row.
1819
     *
1820
     * @param array<string, array<string, array<string, string>|string>|string> $rules
1821
     * @param row_array                                                         $row
1822
     *
1823
     * @return array<string, array<string, array<string, string>|string>|string>
1824
     */
1825
    protected function cleanValidationRules(array $rules, array $row): array
1826
    {
1827
        if ($row === []) {
21✔
1828
            return [];
2✔
1829
        }
1830

1831
        foreach (array_keys($rules) as $field) {
19✔
1832
            if (! array_key_exists($field, $row)) {
19✔
1833
                unset($rules[$field]);
8✔
1834
            }
1835
        }
1836

1837
        return $rules;
19✔
1838
    }
1839

1840
    /**
1841
     * Sets $tempAllowCallbacks value so that we can temporarily override
1842
     * the setting. Resets after the next method that uses triggers.
1843
     *
1844
     * @return $this
1845
     */
1846
    public function allowCallbacks(bool $val = true)
1847
    {
1848
        $this->tempAllowCallbacks = $val;
3✔
1849

1850
        return $this;
3✔
1851
    }
1852

1853
    /**
1854
     * A simple event trigger for Model Events that allows additional
1855
     * data manipulation within the model. Specifically intended for
1856
     * usage by child models this can be used to format data,
1857
     * save/load related classes, etc.
1858
     *
1859
     * It is the responsibility of the callback methods to return
1860
     * the data itself.
1861
     *
1862
     * Each $eventData array MUST have a 'data' key with the relevant
1863
     * data for callback methods (like an array of key/value pairs to insert
1864
     * or update, an array of results, etc.)
1865
     *
1866
     * If callbacks are not allowed then returns $eventData immediately.
1867
     *
1868
     * @template TEventData of array<string, mixed>
1869
     *
1870
     * @param string     $event     Valid property of the model event: $this->before*, $this->after*, etc.
1871
     * @param TEventData $eventData
1872
     *
1873
     * @return TEventData
1874
     *
1875
     * @throws DataException
1876
     */
1877
    protected function trigger(string $event, array $eventData)
1878
    {
1879
        // Ensure it's a valid event
1880
        if (! isset($this->{$event}) || $this->{$event} === []) {
241✔
1881
            return $eventData;
226✔
1882
        }
1883

1884
        foreach ($this->{$event} as $callback) {
15✔
1885
            if (! method_exists($this, $callback)) {
15✔
1886
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1887
            }
1888

1889
            $eventData = $this->{$callback}($eventData);
14✔
1890
        }
1891

1892
        return $eventData;
14✔
1893
    }
1894

1895
    /**
1896
     * Sets the return type of the results to be as an associative array.
1897
     *
1898
     * @return $this
1899
     */
1900
    public function asArray()
1901
    {
1902
        $this->tempReturnType = 'array';
31✔
1903

1904
        return $this;
31✔
1905
    }
1906

1907
    /**
1908
     * Sets the return type to be of the specified type of object.
1909
     * Defaults to a simple object, but can be any class that has
1910
     * class vars with the same name as the collection columns,
1911
     * or at least allows them to be created.
1912
     *
1913
     * @param 'object'|class-string $class
1914
     *
1915
     * @return $this
1916
     */
1917
    public function asObject(string $class = 'object')
1918
    {
1919
        $this->tempReturnType = $class;
20✔
1920

1921
        return $this;
20✔
1922
    }
1923

1924
    /**
1925
     * Takes a class and returns an array of its public and protected
1926
     * properties as an array suitable for use in creates and updates.
1927
     * This method uses `$this->objectToRawArray()` internally and does conversion
1928
     * to string on all Time instances.
1929
     *
1930
     * @param object $object
1931
     * @param bool   $onlyChanged Returns only the changed properties.
1932
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1933
     *
1934
     * @return array<string, mixed>
1935
     *
1936
     * @throws ReflectionException
1937
     */
1938
    protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
1939
    {
1940
        $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
27✔
1941

1942
        // Convert any Time instances to appropriate $dateFormat
1943
        return $this->timeToString($properties);
27✔
1944
    }
1945

1946
    /**
1947
     * Convert any Time instances to appropriate $dateFormat.
1948
     *
1949
     * @param array<string, mixed> $properties
1950
     *
1951
     * @return array<string, mixed>
1952
     */
1953
    protected function timeToString(array $properties): array
1954
    {
1955
        if ($properties === []) {
201✔
1956
            return [];
1✔
1957
        }
1958

1959
        return array_map(function ($value) {
200✔
1960
            if ($value instanceof Time) {
200✔
1961
                return $this->timeToDate($value);
5✔
1962
            }
1963

1964
            return $value;
200✔
1965
        }, $properties);
200✔
1966
    }
1967

1968
    /**
1969
     * Takes a class and returns an array of its public and protected
1970
     * properties as an array with raw values.
1971
     *
1972
     * @param object $object
1973
     * @param bool   $onlyChanged Returns only the changed properties.
1974
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1975
     *
1976
     * @return array<string, mixed> Array with raw values
1977
     *
1978
     * @throws ReflectionException
1979
     */
1980
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
1981
    {
1982
        // Entity::toRawArray() returns array
1983
        if (method_exists($object, 'toRawArray')) {
31✔
1984
            $properties = $object->toRawArray($onlyChanged, $recursive);
28✔
1985
        } else {
1986
            $mirror = new ReflectionClass($object);
3✔
1987
            $props  = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
3✔
1988

1989
            $properties = [];
3✔
1990

1991
            // Loop over each property,
1992
            // saving the name/value in a new array we can return
1993
            foreach ($props as $prop) {
3✔
1994
                $properties[$prop->getName()] = $prop->getValue($object);
3✔
1995
            }
1996
        }
1997

1998
        return $properties;
31✔
1999
    }
2000

2001
    /**
2002
     * Transform data to array.
2003
     *
2004
     * @param object|row_array|null $row
2005
     *
2006
     * @return array<int|string, mixed>
2007
     *
2008
     * @throws DataException
2009
     * @throws InvalidArgumentException
2010
     * @throws ReflectionException
2011
     *
2012
     * @used-by insert()
2013
     * @used-by insertBatch()
2014
     * @used-by update()
2015
     * @used-by updateBatch()
2016
     */
2017
    protected function transformDataToArray($row, string $type): array
2018
    {
2019
        if (! in_array($type, ['insert', 'update'], true)) {
205✔
2020
            throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
1✔
2021
        }
2022

2023
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
204✔
2024
            throw DataException::forEmptyDataset($type);
6✔
2025
        }
2026

2027
        // If it validates with entire rules, all fields are needed.
2028
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
201✔
2029
            $onlyChanged = false;
161✔
2030
        } else {
2031
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
71✔
2032
        }
2033

2034
        if ($this->useCasts()) {
201✔
2035
            if (is_array($row)) {
27✔
2036
                $row = $this->converter->toDataSource($row);
27✔
2037
            } elseif ($row instanceof stdClass) {
7✔
2038
                $row = (array) $row;
3✔
2039
                $row = $this->converter->toDataSource($row);
3✔
2040
            } elseif ($row instanceof Entity) {
4✔
2041
                $row = $this->converter->extract($row, $onlyChanged);
2✔
2042
            } elseif (is_object($row)) {
2✔
2043
                $row = $this->converter->extract($row, $onlyChanged);
2✔
2044
            }
2045
        }
2046
        // If $row is using a custom class with public or protected
2047
        // properties representing the collection elements, we need to grab
2048
        // them as an array.
2049
        elseif (is_object($row) && ! $row instanceof stdClass) {
174✔
2050
            $row = $this->objectToArray($row, $onlyChanged, true);
26✔
2051
        }
2052

2053
        // If it's still a stdClass, go ahead and convert to
2054
        // an array so doProtectFields and other model methods
2055
        // don't have to do special checks.
2056
        if (is_object($row)) {
201✔
2057
            $row = (array) $row;
16✔
2058
        }
2059

2060
        // If it's still empty here, means $row is no change or is empty object
2061
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
201✔
2062
            throw DataException::forEmptyDataset($type);
×
2063
        }
2064

2065
        // Convert any Time instances to appropriate $dateFormat
2066
        return $this->timeToString($row);
201✔
2067
    }
2068

2069
    /**
2070
     * Provides the DB connection and model's properties.
2071
     *
2072
     * @return mixed
2073
     */
2074
    public function __get(string $name)
2075
    {
2076
        if (property_exists($this, $name)) {
50✔
2077
            return $this->{$name};
50✔
2078
        }
2079

2080
        return $this->db->{$name} ?? null;
1✔
2081
    }
2082

2083
    /**
2084
     * Checks for the existence of properties across this model, and DB connection.
2085
     */
2086
    public function __isset(string $name): bool
2087
    {
2088
        if (property_exists($this, $name)) {
50✔
2089
            return true;
50✔
2090
        }
2091

2092
        return isset($this->db->{$name});
1✔
2093
    }
2094

2095
    /**
2096
     * Provides direct access to method in the database connection.
2097
     *
2098
     * @param array<int|string, mixed> $params
2099
     *
2100
     * @return mixed
2101
     */
2102
    public function __call(string $name, array $params)
2103
    {
2104
        if (method_exists($this->db, $name)) {
×
2105
            return $this->db->{$name}(...$params);
×
2106
        }
2107

2108
        return null;
×
2109
    }
2110

2111
    /**
2112
     * Sets $allowEmptyInserts.
2113
     */
2114
    public function allowEmptyInserts(bool $value = true): self
2115
    {
2116
        $this->allowEmptyInserts = $value;
1✔
2117

2118
        return $this;
1✔
2119
    }
2120

2121
    /**
2122
     * Converts database data array to return type value.
2123
     *
2124
     * @param array<string, mixed>          $row        Raw data from database.
2125
     * @param 'array'|'object'|class-string $returnType
2126
     *
2127
     * @return array<string, mixed>|object
2128
     */
2129
    protected function convertToReturnType(array $row, string $returnType): array|object
2130
    {
2131
        if ($returnType === 'array') {
25✔
2132
            return $this->converter->fromDataSource($row);
10✔
2133
        }
2134

2135
        if ($returnType === 'object') {
17✔
2136
            return (object) $this->converter->fromDataSource($row);
5✔
2137
        }
2138

2139
        return $this->converter->reconstruct($returnType, $row);
12✔
2140
    }
2141
}
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