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

codeigniter4 / CodeIgniter4 / 22501820269

27 Feb 2026 08:03PM UTC coverage: 86.631% (+0.009%) from 86.622%
22501820269

Pull #10012

github

web-flow
Merge 011ed365a into de063a8ae
Pull Request #10012: feat: add `Model::firstOrInsert()` with failure handling

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

15 existing lines in 1 file now uncovered.

22531 of 26008 relevant lines covered (86.63%)

219.66 hits per line

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

95.64
/system/BaseModel.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter;
15

16
use Closure;
17
use CodeIgniter\Database\BaseConnection;
18
use CodeIgniter\Database\BaseResult;
19
use CodeIgniter\Database\Exceptions\DatabaseException;
20
use CodeIgniter\Database\Exceptions\DataException;
21
use CodeIgniter\Database\Query;
22
use CodeIgniter\Database\RawSql;
23
use CodeIgniter\DataCaster\Cast\CastInterface;
24
use CodeIgniter\DataConverter\DataConverter;
25
use CodeIgniter\Entity\Cast\CastInterface as EntityCastInterface;
26
use CodeIgniter\Entity\Entity;
27
use CodeIgniter\Exceptions\InvalidArgumentException;
28
use CodeIgniter\Exceptions\ModelException;
29
use CodeIgniter\I18n\Time;
30
use CodeIgniter\Pager\Pager;
31
use CodeIgniter\Validation\ValidationInterface;
32
use Config\Feature;
33
use ReflectionClass;
34
use ReflectionException;
35
use ReflectionProperty;
36
use stdClass;
37

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

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

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

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

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

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

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

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

128
    protected ?DataConverter $converter = null;
129

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

143
    /**
144
     * An array of field names that are allowed
145
     * to be set by the user in inserts/updates.
146
     *
147
     * @var list<string>
148
     */
149
    protected $allowedFields = [];
150

151
    /**
152
     * If true, will set created_at, and updated_at
153
     * values during insert and update routines.
154
     *
155
     * @var bool
156
     */
157
    protected $useTimestamps = false;
158

159
    /**
160
     * The type of column that created_at and updated_at
161
     * are expected to.
162
     *
163
     * @var 'date'|'datetime'|'int'
164
     */
165
    protected $dateFormat = 'datetime';
166

167
    /**
168
     * The column used for insert timestamps.
169
     *
170
     * @var string
171
     */
172
    protected $createdField = 'created_at';
173

174
    /**
175
     * The column used for update timestamps.
176
     *
177
     * @var string
178
     */
179
    protected $updatedField = 'updated_at';
180

181
    /**
182
     * If this model should use "softDeletes" and
183
     * simply set a date when rows are deleted, or
184
     * do hard deletes.
185
     *
186
     * @var bool
187
     */
188
    protected $useSoftDeletes = false;
189

190
    /**
191
     * Used by $this->withDeleted() to override the
192
     * model's "softDelete" setting.
193
     *
194
     * @var bool
195
     */
196
    protected $tempUseSoftDeletes;
197

198
    /**
199
     * The column used to save soft delete state.
200
     *
201
     * @var string
202
     */
203
    protected $deletedField = 'deleted_at';
204

205
    /**
206
     * Whether to allow inserting empty data.
207
     */
208
    protected bool $allowEmptyInserts = false;
209

210
    /**
211
     * Whether to update Entity's only changed data.
212
     */
213
    protected bool $updateOnlyChanged = true;
214

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

228
    /**
229
     * Contains any custom error messages to be
230
     * used during data validation.
231
     *
232
     * @var array<string, array<string, string>> The column is used as the keys.
233
     */
234
    protected $validationMessages = [];
235

236
    /**
237
     * Skip the model's validation.
238
     *
239
     * Used in conjunction with `$this->skipValidation()`
240
     * to skip data validation for any future calls.
241
     *
242
     * @var bool
243
     */
244
    protected $skipValidation = false;
245

246
    /**
247
     * Whether rules should be removed that do not exist
248
     * in the passed data. Used in updates.
249
     *
250
     * @var bool
251
     */
252
    protected $cleanValidationRules = true;
253

254
    /**
255
     * Our validator instance.
256
     *
257
     * @var ValidationInterface|null
258
     */
259
    protected $validation;
260

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

274
    /**
275
     * Whether to trigger the defined callbacks.
276
     *
277
     * @var bool
278
     */
279
    protected $allowCallbacks = true;
280

281
    /**
282
     * Used by $this->allowCallbacks() to override the
283
     * model's $allowCallbacks setting.
284
     *
285
     * @var bool
286
     */
287
    protected $tempAllowCallbacks;
288

289
    /**
290
     * Callbacks for "beforeInsert" event.
291
     *
292
     * @var list<string>
293
     */
294
    protected $beforeInsert = [];
295

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

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

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

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

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

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

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

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

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

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

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

373
    public function __construct(?ValidationInterface $validation = null)
374
    {
375
        $this->tempReturnType     = $this->returnType;
416✔
376
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
416✔
377
        $this->tempAllowCallbacks = $this->allowCallbacks;
416✔
378

379
        $this->validation = $validation;
416✔
380

381
        $this->initialize();
416✔
382
        $this->createDataConverter();
416✔
383
    }
384

385
    /**
386
     * Creates DataConverter instance.
387
     */
388
    protected function createDataConverter(): void
389
    {
390
        if ($this->useCasts()) {
416✔
391
            $this->converter = new DataConverter(
30✔
392
                $this->casts,
30✔
393
                $this->castHandlers,
30✔
394
                $this->db,
30✔
395
            );
30✔
396
        }
397
    }
398

399
    /**
400
     * Are casts used?
401
     */
402
    protected function useCasts(): bool
403
    {
404
        return $this->casts !== [];
416✔
405
    }
406

407
    /**
408
     * Initializes the instance with any additional steps.
409
     * Optionally implemented by child classes.
410
     *
411
     * @return void
412
     */
413
    protected function initialize()
414
    {
415
    }
415✔
416

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

429
    /**
430
     * Fetches the column of database.
431
     * This method works only with DB calls.
432
     *
433
     * @return list<row_array>|null The resulting row of data or `null` if no data found.
434
     *
435
     * @throws DataException
436
     */
437
    abstract protected function doFindColumn(string $columnName);
438

439
    /**
440
     * Fetches all results, while optionally limiting them.
441
     * This method works only with DB calls.
442
     *
443
     * @return list<object|row_array>
444
     */
445
    abstract protected function doFindAll(?int $limit = null, int $offset = 0);
446

447
    /**
448
     * Returns the first row of the result set.
449
     * This method works only with DB calls.
450
     *
451
     * @return object|row_array|null
452
     */
453
    abstract protected function doFirst();
454

455
    /**
456
     * Inserts data into the current database.
457
     * This method works only with DB calls.
458
     *
459
     * @param row_array $row
460
     *
461
     * @return bool
462
     */
463
    abstract protected function doInsert(array $row);
464

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

478
    /**
479
     * Updates a single record in the database.
480
     * This method works only with DB calls.
481
     *
482
     * @param int|list<int|string>|string|null $id
483
     * @param row_array|null                   $row
484
     */
485
    abstract protected function doUpdate($id = null, $row = null): bool;
486

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

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

516
    /**
517
     * Permanently deletes all rows that have been marked as deleted
518
     * through soft deletes (value of column $deletedField is not null).
519
     * This method works only with DB calls.
520
     *
521
     * @return bool|string Returns a SQL string if in test mode.
522
     */
523
    abstract protected function doPurgeDeleted();
524

525
    /**
526
     * Works with the $this->find* methods to return only the rows that
527
     * have been deleted (value of column $deletedField is not null).
528
     * This method works only with DB calls.
529
     *
530
     * @return void
531
     */
532
    abstract protected function doOnlyDeleted();
533

534
    /**
535
     * Compiles a replace and runs the query.
536
     * This method works only with DB calls.
537
     *
538
     * @param row_array|null $row
539
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query.
540
     *
541
     * @return BaseResult|false|Query|string
542
     */
543
    abstract protected function doReplace(?array $row = null, bool $returnSQL = false);
544

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

553
    /**
554
     * Public getter to return the ID value for the data array or object.
555
     * For example with SQL this will return `$data->{$this->primaryKey}`.
556
     *
557
     * @param object|row_array $row
558
     *
559
     * @return int|string|null
560
     */
561
    abstract public function getIdValue($row);
562

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

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

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

605
    /**
606
     * Fetches the row of database.
607
     *
608
     * @param int|list<int|string>|string|null $id One primary key or an array of primary keys.
609
     *
610
     * @return ($id is int|string ? object|row_array|null :  list<object|row_array>)
611
     */
612
    public function find($id = null)
613
    {
614
        $singleton = is_numeric($id) || is_string($id);
58✔
615

616
        if ($this->tempAllowCallbacks) {
58✔
617
            // Call the before event and check for a return
618
            $eventData = $this->trigger('beforeFind', [
55✔
619
                'id'        => $id,
55✔
620
                'method'    => 'find',
55✔
621
                'singleton' => $singleton,
55✔
622
            ]);
55✔
623

624
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
55✔
625
                return $eventData['data'];
3✔
626
            }
627
        }
628

629
        $eventData = [
56✔
630
            'id'        => $id,
56✔
631
            'data'      => $this->doFind($singleton, $id),
56✔
632
            'method'    => 'find',
56✔
633
            'singleton' => $singleton,
56✔
634
        ];
56✔
635

636
        if ($this->tempAllowCallbacks) {
55✔
637
            $eventData = $this->trigger('afterFind', $eventData);
52✔
638
        }
639

640
        $this->tempReturnType     = $this->returnType;
55✔
641
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
55✔
642
        $this->tempAllowCallbacks = $this->allowCallbacks;
55✔
643

644
        return $eventData['data'];
55✔
645
    }
646

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

660
        $resultSet = $this->doFindColumn($columnName);
2✔
661

662
        return $resultSet !== null ? array_column($resultSet, $columnName) : null;
2✔
663
    }
664

665
    /**
666
     * Fetches all results, while optionally limiting them.
667
     *
668
     * @return list<object|row_array>
669
     */
670
    public function findAll(?int $limit = null, int $offset = 0)
671
    {
672
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true;
22✔
673
        if ($limitZeroAsAll) {
22✔
674
            $limit ??= 0;
22✔
675
        }
676

677
        if ($this->tempAllowCallbacks) {
22✔
678
            // Call the before event and check for a return
679
            $eventData = $this->trigger('beforeFind', [
22✔
680
                'method'    => 'findAll',
22✔
681
                'limit'     => $limit,
22✔
682
                'offset'    => $offset,
22✔
683
                'singleton' => false,
22✔
684
            ]);
22✔
685

686
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
22✔
687
                return $eventData['data'];
1✔
688
            }
689
        }
690

691
        $eventData = [
22✔
692
            'data'      => $this->doFindAll($limit, $offset),
22✔
693
            'limit'     => $limit,
22✔
694
            'offset'    => $offset,
22✔
695
            'method'    => 'findAll',
22✔
696
            'singleton' => false,
22✔
697
        ];
22✔
698

699
        if ($this->tempAllowCallbacks) {
22✔
700
            $eventData = $this->trigger('afterFind', $eventData);
22✔
701
        }
702

703
        $this->tempReturnType     = $this->returnType;
22✔
704
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
22✔
705
        $this->tempAllowCallbacks = $this->allowCallbacks;
22✔
706

707
        return $eventData['data'];
22✔
708
    }
709

710
    /**
711
     * Returns the first row of the result set.
712
     *
713
     * @return object|row_array|null
714
     */
715
    public function first()
716
    {
717
        if ($this->tempAllowCallbacks) {
36✔
718
            // Call the before event and check for a return
719
            $eventData = $this->trigger('beforeFind', [
36✔
720
                'method'    => 'first',
36✔
721
                'singleton' => true,
36✔
722
            ]);
36✔
723

724
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
36✔
725
                return $eventData['data'];
1✔
726
            }
727
        }
728

729
        $eventData = [
36✔
730
            'data'      => $this->doFirst(),
36✔
731
            'method'    => 'first',
36✔
732
            'singleton' => true,
36✔
733
        ];
36✔
734

735
        if ($this->tempAllowCallbacks) {
36✔
736
            $eventData = $this->trigger('afterFind', $eventData);
36✔
737
        }
738

739
        $this->tempReturnType     = $this->returnType;
36✔
740
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
36✔
741
        $this->tempAllowCallbacks = $this->allowCallbacks;
36✔
742

743
        return $eventData['data'];
36✔
744
    }
745

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

765
        if ($this->shouldUpdate($row)) {
27✔
766
            $response = $this->update($this->getIdValue($row), $row);
17✔
767
        } else {
768
            $response = $this->insert($row, false);
14✔
769

770
            if ($response !== false) {
13✔
771
                $response = true;
12✔
772
            }
773
        }
774

775
        return $response;
26✔
776
    }
777

778
    /**
779
     * This method is called on save to determine if entry have to be updated.
780
     * If this method returns `false` insert operation will be executed.
781
     *
782
     * @param object|row_array $row
783
     */
784
    protected function shouldUpdate($row): bool
785
    {
786
        $id = $this->getIdValue($row);
27✔
787

788
        return ! in_array($id, [null, [], ''], true);
27✔
789
    }
790

791
    /**
792
     * Returns last insert ID or 0.
793
     *
794
     * @return int|string
795
     */
796
    public function getInsertID()
797
    {
798
        return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID;
11✔
799
    }
800

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

820
            // Check for empty array
821
            if ($id === []) {
122✔
822
                throw new InvalidArgumentException('Invalid primary key: cannot be an empty array.');
5✔
823
            }
824

825
            // Validate each ID in the array recursively
826
            foreach ($id as $key => $valueId) {
117✔
827
                if (is_array($valueId)) {
117✔
828
                    throw new InvalidArgumentException(
5✔
829
                        sprintf('Invalid primary key at index %s: nested arrays are not allowed.', $key),
5✔
830
                    );
5✔
831
                }
832

833
                // Recursive call for each value (single values only in recursion)
834
                $this->validateID($valueId, false);
112✔
835
            }
836

837
            return;
62✔
838
        }
839

840
        // Allow RawSql objects for complex scenarios
841
        if ($id instanceof RawSql) {
129✔
842
            return;
2✔
843
        }
844

845
        // Check for invalid single values
846
        if (in_array($id, [null, 0, '0', '', true, false], true)) {
127✔
847
            $type = is_bool($id) ? 'boolean ' . var_export($id, true) : var_export($id, true);
60✔
848

849
            throw new InvalidArgumentException(
60✔
850
                sprintf('Invalid primary key: %s is not allowed.', $type),
60✔
851
            );
60✔
852
        }
853

854
        // Only allow int and string at this point
855
        if (! is_int($id) && ! is_string($id)) {
97✔
UNCOV
856
            throw new InvalidArgumentException(
×
857
                sprintf('Invalid primary key: must be int or string, %s given.', get_debug_type($id)),
×
858
            );
×
859
        }
860
    }
861

862
    /**
863
     * Inserts data into the database. If an object is provided,
864
     * it will attempt to convert it to an array.
865
     *
866
     * @param object|row_array|null $row
867
     * @param bool                  $returnID Whether insert ID should be returned or not.
868
     *
869
     * @return ($returnID is true ? false|int|string : bool)
870
     *
871
     * @throws ReflectionException
872
     */
873
    public function insert($row = null, bool $returnID = true)
874
    {
875
        $this->insertID = 0;
127✔
876

877
        // Set $cleanValidationRules to false temporary.
878
        $cleanValidationRules       = $this->cleanValidationRules;
127✔
879
        $this->cleanValidationRules = false;
127✔
880

881
        $row = $this->transformDataToArray($row, 'insert');
127✔
882

883
        // Validate data before saving.
884
        if (! $this->skipValidation && ! $this->validate($row)) {
125✔
885
            // Restore $cleanValidationRules
886
            $this->cleanValidationRules = $cleanValidationRules;
20✔
887

888
            return false;
20✔
889
        }
890

891
        // Restore $cleanValidationRules
892
        $this->cleanValidationRules = $cleanValidationRules;
107✔
893

894
        // Must be called first, so we don't
895
        // strip out created_at values.
896
        $row = $this->doProtectFieldsForInsert($row);
107✔
897

898
        // doProtectFields() can further remove elements from
899
        // $row, so we need to check for empty dataset again
900
        if (! $this->allowEmptyInserts && $row === []) {
106✔
901
            throw DataException::forEmptyDataset('insert');
2✔
902
        }
903

904
        // Set created_at and updated_at with same time
905
        $date = $this->setDate();
104✔
906
        $row  = $this->setCreatedField($row, $date);
104✔
907
        $row  = $this->setUpdatedField($row, $date);
104✔
908

909
        $eventData = ['data' => $row];
104✔
910

911
        if ($this->tempAllowCallbacks) {
104✔
912
            $eventData = $this->trigger('beforeInsert', $eventData);
104✔
913
        }
914

915
        $result = $this->doInsert($eventData['data']);
103✔
916

917
        $eventData = [
91✔
918
            'id'     => $this->insertID,
91✔
919
            'data'   => $eventData['data'],
91✔
920
            'result' => $result,
91✔
921
        ];
91✔
922

923
        if ($this->tempAllowCallbacks) {
91✔
924
            // Trigger afterInsert events with the inserted data and new ID
925
            $this->trigger('afterInsert', $eventData);
91✔
926
        }
927

928
        $this->tempAllowCallbacks = $this->allowCallbacks;
91✔
929

930
        // If insertion failed, get out of here
931
        if (! $result) {
91✔
932
            return $result;
4✔
933
        }
934

935
        // otherwise return the insertID, if requested.
936
        return $returnID ? $this->insertID : $result;
87✔
937
    }
938

939
    /**
940
     * Set datetime to created field.
941
     *
942
     * @param row_array  $row
943
     * @param int|string $date Timestamp or datetime string.
944
     *
945
     * @return row_array
946
     */
947
    protected function setCreatedField(array $row, $date): array
948
    {
949
        if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) {
126✔
950
            $row[$this->createdField] = $date;
39✔
951
        }
952

953
        return $row;
126✔
954
    }
955

956
    /**
957
     * Set datetime to updated field.
958
     *
959
     * @param row_array  $row
960
     * @param int|string $date Timestamp or datetime string
961
     *
962
     * @return row_array
963
     */
964
    protected function setUpdatedField(array $row, $date): array
965
    {
966
        if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) {
143✔
967
            $row[$this->updatedField] = $date;
43✔
968
        }
969

970
        return $row;
143✔
971
    }
972

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

991
        if (is_array($set)) {
24✔
992
            foreach ($set as &$row) {
24✔
993
                $row = $this->transformDataToArray($row, 'insert');
24✔
994

995
                // Validate every row.
996
                if (! $this->skipValidation && ! $this->validate($row)) {
24✔
997
                    // Restore $cleanValidationRules
998
                    $this->cleanValidationRules = $cleanValidationRules;
3✔
999

1000
                    return false;
3✔
1001
                }
1002

1003
                // Must be called first so we don't
1004
                // strip out created_at values.
1005
                $row = $this->doProtectFieldsForInsert($row);
21✔
1006

1007
                // Set created_at and updated_at with same time
1008
                $date = $this->setDate();
21✔
1009
                $row  = $this->setCreatedField($row, $date);
21✔
1010
                $row  = $this->setUpdatedField($row, $date);
21✔
1011
            }
1012
        }
1013

1014
        // Restore $cleanValidationRules
1015
        $this->cleanValidationRules = $cleanValidationRules;
21✔
1016

1017
        $eventData = ['data' => $set];
21✔
1018

1019
        if ($this->tempAllowCallbacks) {
21✔
1020
            $eventData = $this->trigger('beforeInsertBatch', $eventData);
21✔
1021
        }
1022

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

1025
        $eventData = [
11✔
1026
            'data'   => $eventData['data'],
11✔
1027
            'result' => $result,
11✔
1028
        ];
11✔
1029

1030
        if ($this->tempAllowCallbacks) {
11✔
1031
            // Trigger afterInsert events with the inserted data and new ID
1032
            $this->trigger('afterInsertBatch', $eventData);
11✔
1033
        }
1034

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

1037
        return $result;
11✔
1038
    }
1039

1040
    /**
1041
     * Updates a single record in the database. If an object is provided,
1042
     * it will attempt to convert it into an array.
1043
     *
1044
     * @param int|list<int|string>|RawSql|string|null $id
1045
     * @param object|row_array|null                   $row
1046
     *
1047
     * @throws ReflectionException
1048
     */
1049
    public function update($id = null, $row = null): bool
1050
    {
1051
        if ($id !== null) {
58✔
1052
            if (! is_array($id)) {
52✔
1053
                $id = [$id];
44✔
1054
            }
1055

1056
            $this->validateID($id);
52✔
1057
        }
1058

1059
        $row = $this->transformDataToArray($row, 'update');
46✔
1060

1061
        // Validate data before saving.
1062
        if (! $this->skipValidation && ! $this->validate($row)) {
44✔
1063
            return false;
4✔
1064
        }
1065

1066
        // Must be called first, so we don't
1067
        // strip out updated_at values.
1068
        $row = $this->doProtectFields($row);
40✔
1069

1070
        // doProtectFields() can further remove elements from
1071
        // $row, so we need to check for empty dataset again
1072
        if ($row === []) {
40✔
1073
            throw DataException::forEmptyDataset('update');
2✔
1074
        }
1075

1076
        $row = $this->setUpdatedField($row, $this->setDate());
38✔
1077

1078
        $eventData = [
38✔
1079
            'id'   => $id,
38✔
1080
            'data' => $row,
38✔
1081
        ];
38✔
1082

1083
        if ($this->tempAllowCallbacks) {
38✔
1084
            $eventData = $this->trigger('beforeUpdate', $eventData);
38✔
1085
        }
1086

1087
        $eventData = [
38✔
1088
            'id'     => $id,
38✔
1089
            'data'   => $eventData['data'],
38✔
1090
            'result' => $this->doUpdate($id, $eventData['data']),
38✔
1091
        ];
38✔
1092

1093
        if ($this->tempAllowCallbacks) {
37✔
1094
            $this->trigger('afterUpdate', $eventData);
37✔
1095
        }
1096

1097
        $this->tempAllowCallbacks = $this->allowCallbacks;
37✔
1098

1099
        return $eventData['result'];
37✔
1100
    }
1101

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

1124
                if ($this->updateOnlyChanged) {
7✔
1125
                    if (is_array($row)) {
6✔
1126
                        $updateIndex = $row[$index] ?? null;
5✔
1127
                    } elseif ($row instanceof Entity) {
1✔
1128
                        $updateIndex = $row->toRawArray()[$index] ?? null;
1✔
UNCOV
1129
                    } elseif (is_object($row)) {
×
UNCOV
1130
                        $updateIndex = $row->{$index} ?? null;
×
1131
                    }
1132
                }
1133

1134
                $row = $this->transformDataToArray($row, 'update');
7✔
1135

1136
                // Validate data before saving.
1137
                if (! $this->skipValidation && ! $this->validate($row)) {
7✔
1138
                    return false;
1✔
1139
                }
1140

1141
                // When updateOnlyChanged is true, restore the pre-extracted
1142
                // index into $row. Otherwise read it from the transformed row.
1143
                if ($updateIndex !== null) {
6✔
1144
                    $row[$index] = $updateIndex;
4✔
1145
                } else {
1146
                    $updateIndex = $row[$index] ?? null;
2✔
1147
                }
1148

1149
                if ($updateIndex === null) {
6✔
1150
                    throw new InvalidArgumentException(
1✔
1151
                        'The index ("' . $index . '") for updateBatch() is missing in the data: '
1✔
1152
                        . json_encode($row),
1✔
1153
                    );
1✔
1154
                }
1155

1156
                // Must be called first so we don't
1157
                // strip out updated_at values.
1158
                $row = $this->doProtectFields($row);
5✔
1159

1160
                // Restore updateIndex value in case it was wiped out
1161
                $row[$index] = $updateIndex;
5✔
1162

1163
                $row = $this->setUpdatedField($row, $this->setDate());
5✔
1164
            }
1165
        }
1166

1167
        $eventData = ['data' => $set];
5✔
1168

1169
        if ($this->tempAllowCallbacks) {
5✔
1170
            $eventData = $this->trigger('beforeUpdateBatch', $eventData);
5✔
1171
        }
1172

1173
        $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL);
5✔
1174

1175
        $eventData = [
5✔
1176
            'data'   => $eventData['data'],
5✔
1177
            'result' => $result,
5✔
1178
        ];
5✔
1179

1180
        if ($this->tempAllowCallbacks) {
5✔
1181
            // Trigger afterInsert events with the inserted data and new ID
1182
            $this->trigger('afterUpdateBatch', $eventData);
5✔
1183
        }
1184

1185
        $this->tempAllowCallbacks = $this->allowCallbacks;
5✔
1186

1187
        return $result;
5✔
1188
    }
1189

1190
    /**
1191
     * Deletes a single record from the database where $id matches.
1192
     *
1193
     * @param int|list<int|string>|RawSql|string|null $id    The rows primary key(s).
1194
     * @param bool                                    $purge Allows overriding the soft deletes setting.
1195
     *
1196
     * @return bool|string Returns a SQL string if in test mode.
1197
     *
1198
     * @throws DatabaseException
1199
     */
1200
    public function delete($id = null, bool $purge = false)
1201
    {
1202
        if ($id !== null) {
84✔
1203
            if (! is_array($id)) {
70✔
1204
                $id = [$id];
41✔
1205
            }
1206

1207
            $this->validateID($id);
70✔
1208
        }
1209

1210
        $eventData = [
36✔
1211
            'id'    => $id,
36✔
1212
            'purge' => $purge,
36✔
1213
        ];
36✔
1214

1215
        if ($this->tempAllowCallbacks) {
36✔
1216
            $this->trigger('beforeDelete', $eventData);
35✔
1217
        }
1218

1219
        $eventData = [
36✔
1220
            'id'     => $id,
36✔
1221
            'data'   => null,
36✔
1222
            'purge'  => $purge,
36✔
1223
            'result' => $this->doDelete($id, $purge),
36✔
1224
        ];
36✔
1225

1226
        if ($this->tempAllowCallbacks) {
31✔
1227
            $this->trigger('afterDelete', $eventData);
30✔
1228
        }
1229

1230
        $this->tempAllowCallbacks = $this->allowCallbacks;
31✔
1231

1232
        return $eventData['result'];
31✔
1233
    }
1234

1235
    /**
1236
     * Permanently deletes all rows that have been marked as deleted
1237
     * through soft deletes (value of column $deletedField is not null).
1238
     *
1239
     * @return bool|string Returns a SQL string if in test mode.
1240
     */
1241
    public function purgeDeleted()
1242
    {
1243
        if (! $this->useSoftDeletes) {
2✔
1244
            return true;
1✔
1245
        }
1246

1247
        return $this->doPurgeDeleted();
1✔
1248
    }
1249

1250
    /**
1251
     * Sets $useSoftDeletes value so that we can temporarily override
1252
     * the soft deletes settings. Can be used for all find* methods.
1253
     *
1254
     * @return $this
1255
     */
1256
    public function withDeleted(bool $val = true)
1257
    {
1258
        $this->tempUseSoftDeletes = ! $val;
23✔
1259

1260
        return $this;
23✔
1261
    }
1262

1263
    /**
1264
     * Works with the $this->find* methods to return only the rows that
1265
     * have been deleted.
1266
     *
1267
     * @return $this
1268
     */
1269
    public function onlyDeleted()
1270
    {
1271
        $this->tempUseSoftDeletes = false;
1✔
1272
        $this->doOnlyDeleted();
1✔
1273

1274
        return $this;
1✔
1275
    }
1276

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

1292
        $row = (array) $row;
2✔
1293
        $row = $this->setCreatedField($row, $this->setDate());
2✔
1294
        $row = $this->setUpdatedField($row, $this->setDate());
2✔
1295

1296
        return $this->doReplace($row, $returnSQL);
2✔
1297
    }
1298

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

1318
        // Do we have validation errors?
1319
        if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors()) !== []) {
25✔
1320
            return $errors;
23✔
1321
        }
1322

1323
        return $this->doErrors();
2✔
1324
    }
1325

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

1343
        if ($segment !== 0) {
8✔
UNCOV
1344
            $pager->setSegment($segment, $group);
×
1345
        }
1346

1347
        $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
8✔
1348
        // Store it in the Pager library, so it can be paginated in the views.
1349
        $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
8✔
1350
        $perPage     = $this->pager->getPerPage($group);
8✔
1351
        $offset      = ($pager->getCurrentPage($group) - 1) * $perPage;
8✔
1352

1353
        return $this->findAll($perPage, $offset);
8✔
1354
    }
1355

1356
    /**
1357
     * It could be used when you have to change default or override current allowed fields.
1358
     *
1359
     * @param list<string> $allowedFields Array with names of fields.
1360
     *
1361
     * @return $this
1362
     */
1363
    public function setAllowedFields(array $allowedFields)
1364
    {
1365
        $this->allowedFields = $allowedFields;
10✔
1366

1367
        return $this;
10✔
1368
    }
1369

1370
    /**
1371
     * Sets whether or not we should whitelist data set during
1372
     * updates or inserts against $this->availableFields.
1373
     *
1374
     * @return $this
1375
     */
1376
    public function protect(bool $protect = true)
1377
    {
1378
        $this->protectFields = $protect;
12✔
1379

1380
        return $this;
12✔
1381
    }
1382

1383
    /**
1384
     * Ensures that only the fields that are allowed to be updated are
1385
     * in the data array.
1386
     *
1387
     * @used-by update() to protect against mass assignment vulnerabilities.
1388
     * @used-by updateBatch() to protect against mass assignment vulnerabilities.
1389
     *
1390
     * @param row_array $row
1391
     *
1392
     * @return row_array
1393
     *
1394
     * @throws DataException
1395
     */
1396
    protected function doProtectFields(array $row): array
1397
    {
1398
        if (! $this->protectFields) {
45✔
1399
            return $row;
2✔
1400
        }
1401

1402
        if ($this->allowedFields === []) {
43✔
UNCOV
1403
            throw DataException::forInvalidAllowedFields(static::class);
×
1404
        }
1405

1406
        foreach (array_keys($row) as $key) {
43✔
1407
            if (! in_array($key, $this->allowedFields, true)) {
43✔
1408
                unset($row[$key]);
24✔
1409
            }
1410
        }
1411

1412
        return $row;
43✔
1413
    }
1414

1415
    /**
1416
     * Ensures that only the fields that are allowed to be inserted are in
1417
     * the data array.
1418
     *
1419
     * @used-by insert() to protect against mass assignment vulnerabilities.
1420
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1421
     *
1422
     * @param row_array $row
1423
     *
1424
     * @return row_array
1425
     *
1426
     * @throws DataException
1427
     */
1428
    protected function doProtectFieldsForInsert(array $row): array
1429
    {
UNCOV
1430
        return $this->doProtectFields($row);
×
1431
    }
1432

1433
    /**
1434
     * Sets the timestamp or current timestamp if null value is passed.
1435
     *
1436
     * @param int|null $userDate An optional PHP timestamp to be converted
1437
     *
1438
     * @return int|string
1439
     *
1440
     * @throws ModelException
1441
     */
1442
    protected function setDate(?int $userDate = null)
1443
    {
1444
        $currentDate = $userDate ?? Time::now()->getTimestamp();
165✔
1445

1446
        return $this->intToDate($currentDate);
165✔
1447
    }
1448

1449
    /**
1450
     * A utility function to allow child models to use the type of
1451
     * date/time format that they prefer. This is primarily used for
1452
     * setting created_at, updated_at and deleted_at values, but can be
1453
     * used by inheriting classes.
1454
     *
1455
     * The available time formats are:
1456
     *  - 'int'      - Stores the date as an integer timestamp.
1457
     *  - 'datetime' - Stores the data in the SQL datetime format.
1458
     *  - 'date'     - Stores the date (only) in the SQL date format.
1459
     *
1460
     * @return int|string
1461
     *
1462
     * @throws ModelException
1463
     */
1464
    protected function intToDate(int $value)
1465
    {
1466
        return match ($this->dateFormat) {
165✔
1467
            'int'      => $value,
36✔
1468
            'datetime' => date($this->db->dateFormat['datetime'], $value),
127✔
1469
            'date'     => date($this->db->dateFormat['date'], $value),
1✔
1470
            default    => throw ModelException::forNoDateFormat(static::class),
165✔
1471
        };
165✔
1472
    }
1473

1474
    /**
1475
     * Converts Time value to string using $this->dateFormat.
1476
     *
1477
     * The available time formats are:
1478
     *  - 'int'      - Stores the date as an integer timestamp.
1479
     *  - 'datetime' - Stores the data in the SQL datetime format.
1480
     *  - 'date'     - Stores the date (only) in the SQL date format.
1481
     *
1482
     * @return int|string
1483
     */
1484
    protected function timeToDate(Time $value)
1485
    {
1486
        return match ($this->dateFormat) {
5✔
1487
            'datetime' => $value->format($this->db->dateFormat['datetime']),
3✔
1488
            'date'     => $value->format($this->db->dateFormat['date']),
1✔
1489
            'int'      => $value->getTimestamp(),
1✔
1490
            default    => (string) $value,
5✔
1491
        };
5✔
1492
    }
1493

1494
    /**
1495
     * Set the value of the $skipValidation flag.
1496
     *
1497
     * @return $this
1498
     */
1499
    public function skipValidation(bool $skip = true)
1500
    {
1501
        $this->skipValidation = $skip;
2✔
1502

1503
        return $this;
2✔
1504
    }
1505

1506
    /**
1507
     * Allows to set (and reset) validation messages.
1508
     * It could be used when you have to change default or override current validate messages.
1509
     *
1510
     * @param array<string, array<string, string>> $validationMessages
1511
     *
1512
     * @return $this
1513
     */
1514
    public function setValidationMessages(array $validationMessages)
1515
    {
UNCOV
1516
        $this->validationMessages = $validationMessages;
×
1517

1518
        return $this;
×
1519
    }
1520

1521
    /**
1522
     * Allows to set field wise validation message.
1523
     * It could be used when you have to change default or override current validate messages.
1524
     *
1525
     * @param array<string, string> $fieldMessages
1526
     *
1527
     * @return $this
1528
     */
1529
    public function setValidationMessage(string $field, array $fieldMessages)
1530
    {
1531
        $this->validationMessages[$field] = $fieldMessages;
2✔
1532

1533
        return $this;
2✔
1534
    }
1535

1536
    /**
1537
     * Allows to set (and reset) validation rules.
1538
     * It could be used when you have to change default or override current validate rules.
1539
     *
1540
     * @param array<string, array<string, array<string, string>|string>|string> $validationRules
1541
     *
1542
     * @return $this
1543
     */
1544
    public function setValidationRules(array $validationRules)
1545
    {
1546
        $this->validationRules = $validationRules;
2✔
1547

1548
        return $this;
2✔
1549
    }
1550

1551
    /**
1552
     * Allows to set field wise validation rules.
1553
     * It could be used when you have to change default or override current validate rules.
1554
     *
1555
     * @param array<string, array<string, string>|string>|string $fieldRules
1556
     *
1557
     * @return $this
1558
     */
1559
    public function setValidationRule(string $field, $fieldRules)
1560
    {
1561
        $rules = $this->validationRules;
2✔
1562

1563
        // ValidationRules can be either a string, which is the group name,
1564
        // or an array of rules.
1565
        if (is_string($rules)) {
2✔
1566
            $this->ensureValidation();
1✔
1567

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

1570
            $this->validationRules = $rules;
1✔
1571
            $this->validationMessages += $customErrors;
1✔
1572
        }
1573

1574
        $this->validationRules[$field] = $fieldRules;
2✔
1575

1576
        return $this;
2✔
1577
    }
1578

1579
    /**
1580
     * Should validation rules be removed before saving?
1581
     * Most handy when doing updates.
1582
     *
1583
     * @return $this
1584
     */
1585
    public function cleanRules(bool $choice = false)
1586
    {
1587
        $this->cleanValidationRules = $choice;
2✔
1588

1589
        return $this;
2✔
1590
    }
1591

1592
    /**
1593
     * Validate the row data against the validation rules (or the validation group)
1594
     * specified in the class property, $validationRules.
1595
     *
1596
     * @param object|row_array $row
1597
     */
1598
    public function validate($row): bool
1599
    {
1600
        if ($this->skipValidation) {
181✔
UNCOV
1601
            return true;
×
1602
        }
1603

1604
        $rules = $this->getValidationRules();
181✔
1605

1606
        if ($rules === []) {
181✔
1607
            return true;
134✔
1608
        }
1609

1610
        // Validation requires array, so cast away.
1611
        if (is_object($row)) {
47✔
1612
            $row = (array) $row;
2✔
1613
        }
1614

1615
        if ($row === []) {
47✔
UNCOV
1616
            return true;
×
1617
        }
1618

1619
        $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules;
47✔
1620

1621
        // If no data existed that needs validation
1622
        // our job is done here.
1623
        if ($rules === []) {
47✔
1624
            return true;
2✔
1625
        }
1626

1627
        $this->ensureValidation();
45✔
1628

1629
        $this->validation->reset()->setRules($rules, $this->validationMessages);
45✔
1630

1631
        return $this->validation->run($row, null, $this->DBGroup);
45✔
1632
    }
1633

1634
    /**
1635
     * Returns the model's defined validation rules so that they
1636
     * can be used elsewhere, if needed.
1637
     *
1638
     * @param array{only?: list<string>, except?: list<string>} $options Filter the list of rules
1639
     *
1640
     * @return array<string, array<string, array<string, string>|string>|string>
1641
     */
1642
    public function getValidationRules(array $options = []): array
1643
    {
1644
        $rules = $this->validationRules;
183✔
1645

1646
        // ValidationRules can be either a string, which is the group name,
1647
        // or an array of rules.
1648
        if (is_string($rules)) {
183✔
1649
            $this->ensureValidation();
13✔
1650

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

1653
            $this->validationMessages += $customErrors;
13✔
1654
        }
1655

1656
        if (isset($options['except'])) {
183✔
UNCOV
1657
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1658
        } elseif (isset($options['only'])) {
183✔
1659
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1660
        }
1661

1662
        return $rules;
183✔
1663
    }
1664

1665
    protected function ensureValidation(): void
1666
    {
1667
        if ($this->validation === null) {
46✔
1668
            $this->validation = service('validation', null, false);
30✔
1669
        }
1670
    }
1671

1672
    /**
1673
     * Returns the model's validation messages, so they
1674
     * can be used elsewhere, if needed.
1675
     *
1676
     * @return array<string, array<string, string>>
1677
     */
1678
    public function getValidationMessages(): array
1679
    {
1680
        return $this->validationMessages;
2✔
1681
    }
1682

1683
    /**
1684
     * Removes any rules that apply to fields that have not been set
1685
     * currently so that rules don't block updating when only updating
1686
     * a partial row.
1687
     *
1688
     * @param array<string, array<string, array<string, string>|string>|string> $rules
1689
     * @param row_array                                                         $row
1690
     *
1691
     * @return array<string, array<string, array<string, string>|string>|string>
1692
     */
1693
    protected function cleanValidationRules(array $rules, array $row): array
1694
    {
1695
        if ($row === []) {
21✔
1696
            return [];
2✔
1697
        }
1698

1699
        foreach (array_keys($rules) as $field) {
19✔
1700
            if (! array_key_exists($field, $row)) {
19✔
1701
                unset($rules[$field]);
8✔
1702
            }
1703
        }
1704

1705
        return $rules;
19✔
1706
    }
1707

1708
    /**
1709
     * Sets $tempAllowCallbacks value so that we can temporarily override
1710
     * the setting. Resets after the next method that uses triggers.
1711
     *
1712
     * @return $this
1713
     */
1714
    public function allowCallbacks(bool $val = true)
1715
    {
1716
        $this->tempAllowCallbacks = $val;
3✔
1717

1718
        return $this;
3✔
1719
    }
1720

1721
    /**
1722
     * A simple event trigger for Model Events that allows additional
1723
     * data manipulation within the model. Specifically intended for
1724
     * usage by child models this can be used to format data,
1725
     * save/load related classes, etc.
1726
     *
1727
     * It is the responsibility of the callback methods to return
1728
     * the data itself.
1729
     *
1730
     * Each $eventData array MUST have a 'data' key with the relevant
1731
     * data for callback methods (like an array of key/value pairs to insert
1732
     * or update, an array of results, etc.)
1733
     *
1734
     * If callbacks are not allowed then returns $eventData immediately.
1735
     *
1736
     * @template TEventData of array<string, mixed>
1737
     *
1738
     * @param string     $event     Valid property of the model event: $this->before*, $this->after*, etc.
1739
     * @param TEventData $eventData
1740
     *
1741
     * @return TEventData
1742
     *
1743
     * @throws DataException
1744
     */
1745
    protected function trigger(string $event, array $eventData)
1746
    {
1747
        // Ensure it's a valid event
1748
        if (! isset($this->{$event}) || $this->{$event} === []) {
223✔
1749
            return $eventData;
208✔
1750
        }
1751

1752
        foreach ($this->{$event} as $callback) {
15✔
1753
            if (! method_exists($this, $callback)) {
15✔
1754
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1755
            }
1756

1757
            $eventData = $this->{$callback}($eventData);
14✔
1758
        }
1759

1760
        return $eventData;
14✔
1761
    }
1762

1763
    /**
1764
     * Sets the return type of the results to be as an associative array.
1765
     *
1766
     * @return $this
1767
     */
1768
    public function asArray()
1769
    {
1770
        $this->tempReturnType = 'array';
31✔
1771

1772
        return $this;
31✔
1773
    }
1774

1775
    /**
1776
     * Sets the return type to be of the specified type of object.
1777
     * Defaults to a simple object, but can be any class that has
1778
     * class vars with the same name as the collection columns,
1779
     * or at least allows them to be created.
1780
     *
1781
     * @param 'object'|class-string $class
1782
     *
1783
     * @return $this
1784
     */
1785
    public function asObject(string $class = 'object')
1786
    {
1787
        $this->tempReturnType = $class;
18✔
1788

1789
        return $this;
18✔
1790
    }
1791

1792
    /**
1793
     * Takes a class and returns an array of its public and protected
1794
     * properties as an array suitable for use in creates and updates.
1795
     * This method uses `$this->objectToRawArray()` internally and does conversion
1796
     * to string on all Time instances.
1797
     *
1798
     * @param object $object
1799
     * @param bool   $onlyChanged Returns only the changed properties.
1800
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1801
     *
1802
     * @return array<string, mixed>
1803
     *
1804
     * @throws ReflectionException
1805
     */
1806
    protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
1807
    {
1808
        $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
25✔
1809

1810
        // Convert any Time instances to appropriate $dateFormat
1811
        return $this->timeToString($properties);
25✔
1812
    }
1813

1814
    /**
1815
     * Convert any Time instances to appropriate $dateFormat.
1816
     *
1817
     * @param array<string, mixed> $properties
1818
     *
1819
     * @return array<string, mixed>
1820
     */
1821
    protected function timeToString(array $properties): array
1822
    {
1823
        if ($properties === []) {
173✔
1824
            return [];
1✔
1825
        }
1826

1827
        return array_map(function ($value) {
172✔
1828
            if ($value instanceof Time) {
172✔
1829
                return $this->timeToDate($value);
5✔
1830
            }
1831

1832
            return $value;
172✔
1833
        }, $properties);
172✔
1834
    }
1835

1836
    /**
1837
     * Takes a class and returns an array of its public and protected
1838
     * properties as an array with raw values.
1839
     *
1840
     * @param object $object
1841
     * @param bool   $onlyChanged Returns only the changed properties.
1842
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1843
     *
1844
     * @return array<string, mixed> Array with raw values
1845
     *
1846
     * @throws ReflectionException
1847
     */
1848
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
1849
    {
1850
        // Entity::toRawArray() returns array
1851
        if (method_exists($object, 'toRawArray')) {
25✔
1852
            $properties = $object->toRawArray($onlyChanged, $recursive);
23✔
1853
        } else {
1854
            $mirror = new ReflectionClass($object);
2✔
1855
            $props  = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
2✔
1856

1857
            $properties = [];
2✔
1858

1859
            // Loop over each property,
1860
            // saving the name/value in a new array we can return
1861
            foreach ($props as $prop) {
2✔
1862
                $properties[$prop->getName()] = $prop->getValue($object);
2✔
1863
            }
1864
        }
1865

1866
        return $properties;
25✔
1867
    }
1868

1869
    /**
1870
     * Transform data to array.
1871
     *
1872
     * @param object|row_array|null $row
1873
     *
1874
     * @return array<int|string, mixed>
1875
     *
1876
     * @throws DataException
1877
     * @throws InvalidArgumentException
1878
     * @throws ReflectionException
1879
     *
1880
     * @used-by insert()
1881
     * @used-by insertBatch()
1882
     * @used-by update()
1883
     * @used-by updateBatch()
1884
     */
1885
    protected function transformDataToArray($row, string $type): array
1886
    {
1887
        if (! in_array($type, ['insert', 'update'], true)) {
177✔
1888
            throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
1✔
1889
        }
1890

1891
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
176✔
1892
            throw DataException::forEmptyDataset($type);
6✔
1893
        }
1894

1895
        // If it validates with entire rules, all fields are needed.
1896
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
173✔
1897
            $onlyChanged = false;
151✔
1898
        } else {
1899
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
53✔
1900
        }
1901

1902
        if ($this->useCasts()) {
173✔
1903
            if (is_array($row)) {
27✔
1904
                $row = $this->converter->toDataSource($row);
27✔
1905
            } elseif ($row instanceof stdClass) {
7✔
1906
                $row = (array) $row;
3✔
1907
                $row = $this->converter->toDataSource($row);
3✔
1908
            } elseif ($row instanceof Entity) {
4✔
1909
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1910
            } elseif (is_object($row)) {
2✔
1911
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1912
            }
1913
        }
1914
        // If $row is using a custom class with public or protected
1915
        // properties representing the collection elements, we need to grab
1916
        // them as an array.
1917
        elseif (is_object($row) && ! $row instanceof stdClass) {
146✔
1918
            $row = $this->objectToArray($row, $onlyChanged, true);
24✔
1919
        }
1920

1921
        // If it's still a stdClass, go ahead and convert to
1922
        // an array so doProtectFields and other model methods
1923
        // don't have to do special checks.
1924
        if (is_object($row)) {
173✔
1925
            $row = (array) $row;
16✔
1926
        }
1927

1928
        // If it's still empty here, means $row is no change or is empty object
1929
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
173✔
UNCOV
1930
            throw DataException::forEmptyDataset($type);
×
1931
        }
1932

1933
        // Convert any Time instances to appropriate $dateFormat
1934
        return $this->timeToString($row);
173✔
1935
    }
1936

1937
    /**
1938
     * Provides the DB connection and model's properties.
1939
     *
1940
     * @return mixed
1941
     */
1942
    public function __get(string $name)
1943
    {
1944
        if (property_exists($this, $name)) {
50✔
1945
            return $this->{$name};
50✔
1946
        }
1947

1948
        return $this->db->{$name} ?? null;
1✔
1949
    }
1950

1951
    /**
1952
     * Checks for the existence of properties across this model, and DB connection.
1953
     */
1954
    public function __isset(string $name): bool
1955
    {
1956
        if (property_exists($this, $name)) {
50✔
1957
            return true;
50✔
1958
        }
1959

1960
        return isset($this->db->{$name});
1✔
1961
    }
1962

1963
    /**
1964
     * Provides direct access to method in the database connection.
1965
     *
1966
     * @param array<int|string, mixed> $params
1967
     *
1968
     * @return mixed
1969
     */
1970
    public function __call(string $name, array $params)
1971
    {
UNCOV
1972
        if (method_exists($this->db, $name)) {
×
UNCOV
1973
            return $this->db->{$name}(...$params);
×
1974
        }
1975

UNCOV
1976
        return null;
×
1977
    }
1978

1979
    /**
1980
     * Sets $allowEmptyInserts.
1981
     */
1982
    public function allowEmptyInserts(bool $value = true): self
1983
    {
1984
        $this->allowEmptyInserts = $value;
1✔
1985

1986
        return $this;
1✔
1987
    }
1988

1989
    /**
1990
     * Converts database data array to return type value.
1991
     *
1992
     * @param array<string, mixed>          $row        Raw data from database.
1993
     * @param 'array'|'object'|class-string $returnType
1994
     *
1995
     * @return array<string, mixed>|object
1996
     */
1997
    protected function convertToReturnType(array $row, string $returnType): array|object
1998
    {
1999
        if ($returnType === 'array') {
25✔
2000
            return $this->converter->fromDataSource($row);
10✔
2001
        }
2002

2003
        if ($returnType === 'object') {
17✔
2004
            return (object) $this->converter->fromDataSource($row);
5✔
2005
        }
2006

2007
        return $this->converter->reconstruct($returnType, $row);
12✔
2008
    }
2009
}
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