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

codeigniter4 / CodeIgniter4 / 20206812853

14 Dec 2025 10:46AM UTC coverage: 84.531% (+0.01%) from 84.519%
20206812853

Pull #9840

github

web-flow
Merge 2f7abc386 into c9d3dcddd
Pull Request #9840: feat(model): primary key validation

35 of 38 new or added lines in 2 files covered. (92.11%)

14 existing lines in 1 file now uncovered.

21503 of 25438 relevant lines covered (84.53%)

203.55 hits per line

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

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

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

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

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

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

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

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

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

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

127
    protected ?DataConverter $converter = null;
128

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

378
        $this->validation = $validation;
389✔
379

380
        $this->initialize();
389✔
381
        $this->createDataConverter();
389✔
382
    }
383

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

587
    /**
588
     * Fetches the row of database.
589
     *
590
     * @param int|list<int|string>|string|null $id One primary key or an array of primary keys.
591
     *
592
     * @return ($id is int|string ? object|row_array|null :  list<object|row_array>)
593
     */
594
    public function find($id = null)
595
    {
596
        $singleton = is_numeric($id) || is_string($id);
58✔
597

598
        if ($this->tempAllowCallbacks) {
58✔
599
            // Call the before event and check for a return
600
            $eventData = $this->trigger('beforeFind', [
55✔
601
                'id'        => $id,
55✔
602
                'method'    => 'find',
55✔
603
                'singleton' => $singleton,
55✔
604
            ]);
55✔
605

606
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
55✔
607
                return $eventData['data'];
3✔
608
            }
609
        }
610

611
        $eventData = [
56✔
612
            'id'        => $id,
56✔
613
            'data'      => $this->doFind($singleton, $id),
56✔
614
            'method'    => 'find',
56✔
615
            'singleton' => $singleton,
56✔
616
        ];
56✔
617

618
        if ($this->tempAllowCallbacks) {
55✔
619
            $eventData = $this->trigger('afterFind', $eventData);
52✔
620
        }
621

622
        $this->tempReturnType     = $this->returnType;
55✔
623
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
55✔
624
        $this->tempAllowCallbacks = $this->allowCallbacks;
55✔
625

626
        return $eventData['data'];
55✔
627
    }
628

629
    /**
630
     * Fetches the column of database.
631
     *
632
     * @return list<bool|float|int|list<mixed>|object|string|null>|null The resulting row of data, or `null` if no data found.
633
     *
634
     * @throws DataException
635
     */
636
    public function findColumn(string $columnName)
637
    {
638
        if (str_contains($columnName, ',')) {
3✔
639
            throw DataException::forFindColumnHaveMultipleColumns();
1✔
640
        }
641

642
        $resultSet = $this->doFindColumn($columnName);
2✔
643

644
        return $resultSet !== null ? array_column($resultSet, $columnName) : null;
2✔
645
    }
646

647
    /**
648
     * Fetches all results, while optionally limiting them.
649
     *
650
     * @return list<object|row_array>
651
     */
652
    public function findAll(?int $limit = null, int $offset = 0)
653
    {
654
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true;
22✔
655
        if ($limitZeroAsAll) {
22✔
656
            $limit ??= 0;
22✔
657
        }
658

659
        if ($this->tempAllowCallbacks) {
22✔
660
            // Call the before event and check for a return
661
            $eventData = $this->trigger('beforeFind', [
22✔
662
                'method'    => 'findAll',
22✔
663
                'limit'     => $limit,
22✔
664
                'offset'    => $offset,
22✔
665
                'singleton' => false,
22✔
666
            ]);
22✔
667

668
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
22✔
669
                return $eventData['data'];
1✔
670
            }
671
        }
672

673
        $eventData = [
22✔
674
            'data'      => $this->doFindAll($limit, $offset),
22✔
675
            'limit'     => $limit,
22✔
676
            'offset'    => $offset,
22✔
677
            'method'    => 'findAll',
22✔
678
            'singleton' => false,
22✔
679
        ];
22✔
680

681
        if ($this->tempAllowCallbacks) {
22✔
682
            $eventData = $this->trigger('afterFind', $eventData);
22✔
683
        }
684

685
        $this->tempReturnType     = $this->returnType;
22✔
686
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
22✔
687
        $this->tempAllowCallbacks = $this->allowCallbacks;
22✔
688

689
        return $eventData['data'];
22✔
690
    }
691

692
    /**
693
     * Returns the first row of the result set.
694
     *
695
     * @return object|row_array|null
696
     */
697
    public function first()
698
    {
699
        if ($this->tempAllowCallbacks) {
24✔
700
            // Call the before event and check for a return
701
            $eventData = $this->trigger('beforeFind', [
24✔
702
                'method'    => 'first',
24✔
703
                'singleton' => true,
24✔
704
            ]);
24✔
705

706
            if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
24✔
707
                return $eventData['data'];
1✔
708
            }
709
        }
710

711
        $eventData = [
24✔
712
            'data'      => $this->doFirst(),
24✔
713
            'method'    => 'first',
24✔
714
            'singleton' => true,
24✔
715
        ];
24✔
716

717
        if ($this->tempAllowCallbacks) {
24✔
718
            $eventData = $this->trigger('afterFind', $eventData);
24✔
719
        }
720

721
        $this->tempReturnType     = $this->returnType;
24✔
722
        $this->tempUseSoftDeletes = $this->useSoftDeletes;
24✔
723
        $this->tempAllowCallbacks = $this->allowCallbacks;
24✔
724

725
        return $eventData['data'];
24✔
726
    }
727

728
    /**
729
     * A convenience method that will attempt to determine whether the
730
     * data should be inserted or updated.
731
     *
732
     * Will work with either an array or object.
733
     * When using with custom class objects,
734
     * you must ensure that the class will provide access to the class
735
     * variables, even if through a magic method.
736
     *
737
     * @param object|row_array $row
738
     *
739
     * @throws ReflectionException
740
     */
741
    public function save($row): bool
742
    {
743
        if ((array) $row === []) {
28✔
744
            return true;
1✔
745
        }
746

747
        if ($this->shouldUpdate($row)) {
27✔
748
            $response = $this->update($this->getIdValue($row), $row);
17✔
749
        } else {
750
            $response = $this->insert($row, false);
14✔
751

752
            if ($response !== false) {
13✔
753
                $response = true;
12✔
754
            }
755
        }
756

757
        return $response;
26✔
758
    }
759

760
    /**
761
     * This method is called on save to determine if entry have to be updated.
762
     * If this method returns `false` insert operation will be executed.
763
     *
764
     * @param object|row_array $row
765
     */
766
    protected function shouldUpdate($row): bool
767
    {
768
        $id = $this->getIdValue($row);
27✔
769

770
        return ! in_array($id, [null, [], ''], true);
27✔
771
    }
772

773
    /**
774
     * Returns last insert ID or 0.
775
     *
776
     * @return int|string
777
     */
778
    public function getInsertID()
779
    {
780
        return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID;
11✔
781
    }
782

783
    /**
784
     * Validates that the primary key values are valid for update/delete/insert operations.
785
     * Throws exception if invalid.
786
     *
787
     * @param int|list<int|string>|string $id
788
     * @param bool                        $allowArray Whether to allow array of IDs (true for update/delete, false for insert)
789
     *
790
     * @throws InvalidArgumentException
791
     */
792
    protected function validateID($id, bool $allowArray = true): void
793
    {
794
        if (is_array($id)) {
141✔
795
            // Check if arrays are allowed
796
            if (! $allowArray) {
128✔
797
                throw new InvalidArgumentException(
8✔
798
                    'Invalid primary key: only a single value is allowed, not an array.',
8✔
799
                );
8✔
800
            }
801

802
            // Check for empty array
803
            if ($id === []) {
120✔
804
                throw new InvalidArgumentException('Invalid primary key: cannot be an empty array.');
5✔
805
            }
806

807
            // Validate each ID in the array recursively
808
            foreach ($id as $key => $valueId) {
115✔
809
                if (is_array($valueId)) {
115✔
810
                    throw new InvalidArgumentException(
5✔
811
                        sprintf('Invalid primary key at index %s: nested arrays are not allowed.', $key),
5✔
812
                    );
5✔
813
                }
814

815
                // Recursive call for each value (single values only in recursion)
816
                $this->validateID($valueId, false);
110✔
817
            }
818

819
            return;
60✔
820
        }
821

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

826
            throw new InvalidArgumentException(
60✔
827
                sprintf('Invalid primary key: %s is not allowed.', $type),
60✔
828
            );
60✔
829
        }
830

831
        // Only allow int and string at this point
832
        if (! is_int($id) && ! is_string($id)) {
97✔
NEW
833
            throw new InvalidArgumentException(
×
NEW
834
                sprintf('Invalid primary key: must be int or string, %s given.', get_debug_type($id)),
×
NEW
835
            );
×
836
        }
837
    }
838

839
    /**
840
     * Inserts data into the database. If an object is provided,
841
     * it will attempt to convert it to an array.
842
     *
843
     * @param object|row_array|null $row
844
     * @param bool                  $returnID Whether insert ID should be returned or not.
845
     *
846
     * @return ($returnID is true ? false|int|string : bool)
847
     *
848
     * @throws ReflectionException
849
     */
850
    public function insert($row = null, bool $returnID = true)
851
    {
852
        $this->insertID = 0;
119✔
853

854
        // Set $cleanValidationRules to false temporary.
855
        $cleanValidationRules       = $this->cleanValidationRules;
119✔
856
        $this->cleanValidationRules = false;
119✔
857

858
        $row = $this->transformDataToArray($row, 'insert');
119✔
859

860
        // Validate data before saving.
861
        if (! $this->skipValidation && ! $this->validate($row)) {
117✔
862
            // Restore $cleanValidationRules
863
            $this->cleanValidationRules = $cleanValidationRules;
19✔
864

865
            return false;
19✔
866
        }
867

868
        // Restore $cleanValidationRules
869
        $this->cleanValidationRules = $cleanValidationRules;
100✔
870

871
        // Must be called first, so we don't
872
        // strip out created_at values.
873
        $row = $this->doProtectFieldsForInsert($row);
100✔
874

875
        // doProtectFields() can further remove elements from
876
        // $row, so we need to check for empty dataset again
877
        if (! $this->allowEmptyInserts && $row === []) {
99✔
878
            throw DataException::forEmptyDataset('insert');
2✔
879
        }
880

881
        // Set created_at and updated_at with same time
882
        $date = $this->setDate();
97✔
883
        $row  = $this->setCreatedField($row, $date);
97✔
884
        $row  = $this->setUpdatedField($row, $date);
97✔
885

886
        $eventData = ['data' => $row];
97✔
887

888
        if ($this->tempAllowCallbacks) {
97✔
889
            $eventData = $this->trigger('beforeInsert', $eventData);
97✔
890
        }
891

892
        $result = $this->doInsert($eventData['data']);
96✔
893

894
        $eventData = [
85✔
895
            'id'     => $this->insertID,
85✔
896
            'data'   => $eventData['data'],
85✔
897
            'result' => $result,
85✔
898
        ];
85✔
899

900
        if ($this->tempAllowCallbacks) {
85✔
901
            // Trigger afterInsert events with the inserted data and new ID
902
            $this->trigger('afterInsert', $eventData);
85✔
903
        }
904

905
        $this->tempAllowCallbacks = $this->allowCallbacks;
85✔
906

907
        // If insertion failed, get out of here
908
        if (! $result) {
85✔
909
            return $result;
2✔
910
        }
911

912
        // otherwise return the insertID, if requested.
913
        return $returnID ? $this->insertID : $result;
83✔
914
    }
915

916
    /**
917
     * Set datetime to created field.
918
     *
919
     * @param row_array  $row
920
     * @param int|string $date Timestamp or datetime string.
921
     *
922
     * @return row_array
923
     */
924
    protected function setCreatedField(array $row, $date): array
925
    {
926
        if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) {
119✔
927
            $row[$this->createdField] = $date;
39✔
928
        }
929

930
        return $row;
119✔
931
    }
932

933
    /**
934
     * Set datetime to updated field.
935
     *
936
     * @param row_array  $row
937
     * @param int|string $date Timestamp or datetime string
938
     *
939
     * @return row_array
940
     */
941
    protected function setUpdatedField(array $row, $date): array
942
    {
943
        if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) {
134✔
944
            $row[$this->updatedField] = $date;
43✔
945
        }
946

947
        return $row;
134✔
948
    }
949

950
    /**
951
     * Compiles batch insert runs the queries, validating each row prior.
952
     *
953
     * @param list<object|row_array>|null $set       An associative array of insert values.
954
     * @param bool|null                   $escape    Whether to escape values.
955
     * @param int                         $batchSize The size of the batch to run.
956
     * @param bool                        $testing   `true` means only number of records is returned, `false` will execute the query.
957
     *
958
     * @return false|int|list<string> Number of rows inserted or `false` on failure.
959
     *
960
     * @throws ReflectionException
961
     */
962
    public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false)
963
    {
964
        // Set $cleanValidationRules to false temporary.
965
        $cleanValidationRules       = $this->cleanValidationRules;
24✔
966
        $this->cleanValidationRules = false;
24✔
967

968
        if (is_array($set)) {
24✔
969
            foreach ($set as &$row) {
24✔
970
                $row = $this->transformDataToArray($row, 'insert');
24✔
971

972
                // Validate every row.
973
                if (! $this->skipValidation && ! $this->validate($row)) {
24✔
974
                    // Restore $cleanValidationRules
975
                    $this->cleanValidationRules = $cleanValidationRules;
3✔
976

977
                    return false;
3✔
978
                }
979

980
                // Must be called first so we don't
981
                // strip out created_at values.
982
                $row = $this->doProtectFieldsForInsert($row);
21✔
983

984
                // Set created_at and updated_at with same time
985
                $date = $this->setDate();
21✔
986
                $row  = $this->setCreatedField($row, $date);
21✔
987
                $row  = $this->setUpdatedField($row, $date);
21✔
988
            }
989
        }
990

991
        // Restore $cleanValidationRules
992
        $this->cleanValidationRules = $cleanValidationRules;
21✔
993

994
        $eventData = ['data' => $set];
21✔
995

996
        if ($this->tempAllowCallbacks) {
21✔
997
            $eventData = $this->trigger('beforeInsertBatch', $eventData);
21✔
998
        }
999

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

1002
        $eventData = [
11✔
1003
            'data'   => $eventData['data'],
11✔
1004
            'result' => $result,
11✔
1005
        ];
11✔
1006

1007
        if ($this->tempAllowCallbacks) {
11✔
1008
            // Trigger afterInsert events with the inserted data and new ID
1009
            $this->trigger('afterInsertBatch', $eventData);
11✔
1010
        }
1011

1012
        $this->tempAllowCallbacks = $this->allowCallbacks;
11✔
1013

1014
        return $result;
11✔
1015
    }
1016

1017
    /**
1018
     * Updates a single record in the database. If an object is provided,
1019
     * it will attempt to convert it into an array.
1020
     *
1021
     * @param int|list<int|string>|string|null $id
1022
     * @param object|row_array|null            $row
1023
     *
1024
     * @throws ReflectionException
1025
     */
1026
    public function update($id = null, $row = null): bool
1027
    {
1028
        if ($id !== null) {
57✔
1029
            if (! is_array($id)) {
51✔
1030
                $id = [$id];
43✔
1031
            }
1032

1033
            $this->validateID($id);
51✔
1034
        }
1035

1036
        $row = $this->transformDataToArray($row, 'update');
45✔
1037

1038
        // Validate data before saving.
1039
        if (! $this->skipValidation && ! $this->validate($row)) {
43✔
1040
            return false;
4✔
1041
        }
1042

1043
        // Must be called first, so we don't
1044
        // strip out updated_at values.
1045
        $row = $this->doProtectFields($row);
39✔
1046

1047
        // doProtectFields() can further remove elements from
1048
        // $row, so we need to check for empty dataset again
1049
        if ($row === []) {
39✔
1050
            throw DataException::forEmptyDataset('update');
2✔
1051
        }
1052

1053
        $row = $this->setUpdatedField($row, $this->setDate());
37✔
1054

1055
        $eventData = [
37✔
1056
            'id'   => $id,
37✔
1057
            'data' => $row,
37✔
1058
        ];
37✔
1059

1060
        if ($this->tempAllowCallbacks) {
37✔
1061
            $eventData = $this->trigger('beforeUpdate', $eventData);
37✔
1062
        }
1063

1064
        $eventData = [
37✔
1065
            'id'     => $id,
37✔
1066
            'data'   => $eventData['data'],
37✔
1067
            'result' => $this->doUpdate($id, $eventData['data']),
37✔
1068
        ];
37✔
1069

1070
        if ($this->tempAllowCallbacks) {
36✔
1071
            $this->trigger('afterUpdate', $eventData);
36✔
1072
        }
1073

1074
        $this->tempAllowCallbacks = $this->allowCallbacks;
36✔
1075

1076
        return $eventData['result'];
36✔
1077
    }
1078

1079
    /**
1080
     * Compiles an update and runs the query.
1081
     *
1082
     * @param list<object|row_array>|null $set       An associative array of insert values.
1083
     * @param string|null                 $index     The where key.
1084
     * @param int                         $batchSize The size of the batch to run.
1085
     * @param bool                        $returnSQL `true` means SQL is returned, `false` will execute the query.
1086
     *
1087
     * @return false|int|list<string> Number of rows affected or `false` on failure, SQL array when test mode.
1088
     *
1089
     * @throws DatabaseException
1090
     * @throws ReflectionException
1091
     */
1092
    public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
1093
    {
1094
        if (is_array($set)) {
6✔
1095
            foreach ($set as &$row) {
6✔
1096
                $row = $this->transformDataToArray($row, 'update');
6✔
1097

1098
                // Validate data before saving.
1099
                if (! $this->skipValidation && ! $this->validate($row)) {
6✔
1100
                    return false;
1✔
1101
                }
1102

1103
                // Save updateIndex for later
1104
                $updateIndex = $row[$index] ?? null;
5✔
1105

1106
                if ($updateIndex === null) {
5✔
1107
                    throw new InvalidArgumentException(
1✔
1108
                        'The index ("' . $index . '") for updateBatch() is missing in the data: '
1✔
1109
                        . json_encode($row),
1✔
1110
                    );
1✔
1111
                }
1112

1113
                // Must be called first so we don't
1114
                // strip out updated_at values.
1115
                $row = $this->doProtectFields($row);
4✔
1116

1117
                // Restore updateIndex value in case it was wiped out
1118
                $row[$index] = $updateIndex;
4✔
1119

1120
                $row = $this->setUpdatedField($row, $this->setDate());
4✔
1121
            }
1122
        }
1123

1124
        $eventData = ['data' => $set];
4✔
1125

1126
        if ($this->tempAllowCallbacks) {
4✔
1127
            $eventData = $this->trigger('beforeUpdateBatch', $eventData);
4✔
1128
        }
1129

1130
        $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL);
4✔
1131

1132
        $eventData = [
4✔
1133
            'data'   => $eventData['data'],
4✔
1134
            'result' => $result,
4✔
1135
        ];
4✔
1136

1137
        if ($this->tempAllowCallbacks) {
4✔
1138
            // Trigger afterInsert events with the inserted data and new ID
1139
            $this->trigger('afterUpdateBatch', $eventData);
4✔
1140
        }
1141

1142
        $this->tempAllowCallbacks = $this->allowCallbacks;
4✔
1143

1144
        return $result;
4✔
1145
    }
1146

1147
    /**
1148
     * Deletes a single record from the database where $id matches.
1149
     *
1150
     * @param int|list<int|string>|string|null $id    The rows primary key(s).
1151
     * @param bool                             $purge Allows overriding the soft deletes setting.
1152
     *
1153
     * @return bool|string Returns a SQL string if in test mode.
1154
     *
1155
     * @throws DatabaseException
1156
     */
1157
    public function delete($id = null, bool $purge = false)
1158
    {
1159
        if ($id !== null) {
83✔
1160
            if (! is_array($id)) {
69✔
1161
                $id = [$id];
40✔
1162
            }
1163

1164
            $this->validateID($id);
69✔
1165
        }
1166

1167
        $eventData = [
35✔
1168
            'id'    => $id,
35✔
1169
            'purge' => $purge,
35✔
1170
        ];
35✔
1171

1172
        if ($this->tempAllowCallbacks) {
35✔
1173
            $this->trigger('beforeDelete', $eventData);
34✔
1174
        }
1175

1176
        $eventData = [
35✔
1177
            'id'     => $id,
35✔
1178
            'data'   => null,
35✔
1179
            'purge'  => $purge,
35✔
1180
            'result' => $this->doDelete($id, $purge),
35✔
1181
        ];
35✔
1182

1183
        if ($this->tempAllowCallbacks) {
30✔
1184
            $this->trigger('afterDelete', $eventData);
29✔
1185
        }
1186

1187
        $this->tempAllowCallbacks = $this->allowCallbacks;
30✔
1188

1189
        return $eventData['result'];
30✔
1190
    }
1191

1192
    /**
1193
     * Permanently deletes all rows that have been marked as deleted
1194
     * through soft deletes (value of column $deletedField is not null).
1195
     *
1196
     * @return bool|string Returns a SQL string if in test mode.
1197
     */
1198
    public function purgeDeleted()
1199
    {
1200
        if (! $this->useSoftDeletes) {
2✔
1201
            return true;
1✔
1202
        }
1203

1204
        return $this->doPurgeDeleted();
1✔
1205
    }
1206

1207
    /**
1208
     * Sets $useSoftDeletes value so that we can temporarily override
1209
     * the soft deletes settings. Can be used for all find* methods.
1210
     *
1211
     * @return $this
1212
     */
1213
    public function withDeleted(bool $val = true)
1214
    {
1215
        $this->tempUseSoftDeletes = ! $val;
23✔
1216

1217
        return $this;
23✔
1218
    }
1219

1220
    /**
1221
     * Works with the $this->find* methods to return only the rows that
1222
     * have been deleted.
1223
     *
1224
     * @return $this
1225
     */
1226
    public function onlyDeleted()
1227
    {
1228
        $this->tempUseSoftDeletes = false;
1✔
1229
        $this->doOnlyDeleted();
1✔
1230

1231
        return $this;
1✔
1232
    }
1233

1234
    /**
1235
     * Compiles a replace and runs the query.
1236
     *
1237
     * @param row_array|null $row
1238
     * @param bool           $returnSQL `true` means SQL is returned, `false` will execute the query.
1239
     *
1240
     * @return BaseResult|false|Query|string
1241
     */
1242
    public function replace(?array $row = null, bool $returnSQL = false)
1243
    {
1244
        // Validate data before saving.
1245
        if (($row !== null) && ! $this->skipValidation && ! $this->validate($row)) {
3✔
1246
            return false;
1✔
1247
        }
1248

1249
        $row = (array) $row;
2✔
1250
        $row = $this->setCreatedField($row, $this->setDate());
2✔
1251
        $row = $this->setUpdatedField($row, $this->setDate());
2✔
1252

1253
        return $this->doReplace($row, $returnSQL);
2✔
1254
    }
1255

1256
    /**
1257
     * Grabs the last error(s) that occurred.
1258
     *
1259
     * If data was validated, it will first check for errors there,
1260
     *  otherwise will try to grab the last error from the Database connection.
1261
     *
1262
     * The return array should be in the following format:
1263
     *  `['source' => 'message']`.
1264
     *
1265
     * @param bool $forceDB Always grab the DB error, not validation.
1266
     *
1267
     * @return array<string, string>
1268
     */
1269
    public function errors(bool $forceDB = false)
1270
    {
1271
        if ($this->validation === null) {
24✔
UNCOV
1272
            return $this->doErrors();
×
1273
        }
1274

1275
        // Do we have validation errors?
1276
        if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors()) !== []) {
24✔
1277
            return $errors;
22✔
1278
        }
1279

1280
        return $this->doErrors();
2✔
1281
    }
1282

1283
    /**
1284
     * Works with Pager to get the size and offset parameters.
1285
     * Expects a GET variable (?page=2) that specifies the page of results
1286
     * to display.
1287
     *
1288
     * @param int|null $perPage Items per page.
1289
     * @param string   $group   Will be used by the pagination library to identify a unique pagination set.
1290
     * @param int|null $page    Optional page number (useful when the page number is provided in different way).
1291
     * @param int      $segment Optional URI segment number (if page number is provided by URI segment).
1292
     *
1293
     * @return list<object|row_array>
1294
     */
1295
    public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0)
1296
    {
1297
        // Since multiple models may use the Pager, the Pager must be shared.
1298
        $pager = service('pager');
8✔
1299

1300
        if ($segment !== 0) {
8✔
UNCOV
1301
            $pager->setSegment($segment, $group);
×
1302
        }
1303

1304
        $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
8✔
1305
        // Store it in the Pager library, so it can be paginated in the views.
1306
        $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
8✔
1307
        $perPage     = $this->pager->getPerPage($group);
8✔
1308
        $offset      = ($pager->getCurrentPage($group) - 1) * $perPage;
8✔
1309

1310
        return $this->findAll($perPage, $offset);
8✔
1311
    }
1312

1313
    /**
1314
     * It could be used when you have to change default or override current allowed fields.
1315
     *
1316
     * @param list<string> $allowedFields Array with names of fields.
1317
     *
1318
     * @return $this
1319
     */
1320
    public function setAllowedFields(array $allowedFields)
1321
    {
1322
        $this->allowedFields = $allowedFields;
10✔
1323

1324
        return $this;
10✔
1325
    }
1326

1327
    /**
1328
     * Sets whether or not we should whitelist data set during
1329
     * updates or inserts against $this->availableFields.
1330
     *
1331
     * @return $this
1332
     */
1333
    public function protect(bool $protect = true)
1334
    {
1335
        $this->protectFields = $protect;
12✔
1336

1337
        return $this;
12✔
1338
    }
1339

1340
    /**
1341
     * Ensures that only the fields that are allowed to be updated are
1342
     * in the data array.
1343
     *
1344
     * @used-by update() to protect against mass assignment vulnerabilities.
1345
     * @used-by updateBatch() to protect against mass assignment vulnerabilities.
1346
     *
1347
     * @param row_array $row
1348
     *
1349
     * @return row_array
1350
     *
1351
     * @throws DataException
1352
     */
1353
    protected function doProtectFields(array $row): array
1354
    {
1355
        if (! $this->protectFields) {
43✔
1356
            return $row;
2✔
1357
        }
1358

1359
        if ($this->allowedFields === []) {
41✔
UNCOV
1360
            throw DataException::forInvalidAllowedFields(static::class);
×
1361
        }
1362

1363
        foreach (array_keys($row) as $key) {
41✔
1364
            if (! in_array($key, $this->allowedFields, true)) {
41✔
1365
                unset($row[$key]);
23✔
1366
            }
1367
        }
1368

1369
        return $row;
41✔
1370
    }
1371

1372
    /**
1373
     * Ensures that only the fields that are allowed to be inserted are in
1374
     * the data array.
1375
     *
1376
     * @used-by insert() to protect against mass assignment vulnerabilities.
1377
     * @used-by insertBatch() to protect against mass assignment vulnerabilities.
1378
     *
1379
     * @param row_array $row
1380
     *
1381
     * @return row_array
1382
     *
1383
     * @throws DataException
1384
     */
1385
    protected function doProtectFieldsForInsert(array $row): array
1386
    {
UNCOV
1387
        return $this->doProtectFields($row);
×
1388
    }
1389

1390
    /**
1391
     * Sets the timestamp or current timestamp if null value is passed.
1392
     *
1393
     * @param int|null $userDate An optional PHP timestamp to be converted
1394
     *
1395
     * @return int|string
1396
     *
1397
     * @throws ModelException
1398
     */
1399
    protected function setDate(?int $userDate = null)
1400
    {
1401
        $currentDate = $userDate ?? Time::now()->getTimestamp();
156✔
1402

1403
        return $this->intToDate($currentDate);
156✔
1404
    }
1405

1406
    /**
1407
     * A utility function to allow child models to use the type of
1408
     * date/time format that they prefer. This is primarily used for
1409
     * setting created_at, updated_at and deleted_at values, but can be
1410
     * used by inheriting classes.
1411
     *
1412
     * The available time formats are:
1413
     *  - 'int'      - Stores the date as an integer timestamp.
1414
     *  - 'datetime' - Stores the data in the SQL datetime format.
1415
     *  - 'date'     - Stores the date (only) in the SQL date format.
1416
     *
1417
     * @return int|string
1418
     *
1419
     * @throws ModelException
1420
     */
1421
    protected function intToDate(int $value)
1422
    {
1423
        return match ($this->dateFormat) {
156✔
1424
            'int'      => $value,
36✔
1425
            'datetime' => date($this->db->dateFormat['datetime'], $value),
118✔
1426
            'date'     => date($this->db->dateFormat['date'], $value),
1✔
1427
            default    => throw ModelException::forNoDateFormat(static::class),
156✔
1428
        };
156✔
1429
    }
1430

1431
    /**
1432
     * Converts Time value to string using $this->dateFormat.
1433
     *
1434
     * The available time formats are:
1435
     *  - 'int'      - Stores the date as an integer timestamp.
1436
     *  - 'datetime' - Stores the data in the SQL datetime format.
1437
     *  - 'date'     - Stores the date (only) in the SQL date format.
1438
     *
1439
     * @return int|string
1440
     */
1441
    protected function timeToDate(Time $value)
1442
    {
1443
        return match ($this->dateFormat) {
5✔
1444
            'datetime' => $value->format($this->db->dateFormat['datetime']),
3✔
1445
            'date'     => $value->format($this->db->dateFormat['date']),
1✔
1446
            'int'      => $value->getTimestamp(),
1✔
1447
            default    => (string) $value,
5✔
1448
        };
5✔
1449
    }
1450

1451
    /**
1452
     * Set the value of the $skipValidation flag.
1453
     *
1454
     * @return $this
1455
     */
1456
    public function skipValidation(bool $skip = true)
1457
    {
1458
        $this->skipValidation = $skip;
2✔
1459

1460
        return $this;
2✔
1461
    }
1462

1463
    /**
1464
     * Allows to set (and reset) validation messages.
1465
     * It could be used when you have to change default or override current validate messages.
1466
     *
1467
     * @param array<string, array<string, string>> $validationMessages
1468
     *
1469
     * @return $this
1470
     */
1471
    public function setValidationMessages(array $validationMessages)
1472
    {
UNCOV
1473
        $this->validationMessages = $validationMessages;
×
1474

UNCOV
1475
        return $this;
×
1476
    }
1477

1478
    /**
1479
     * Allows to set field wise validation message.
1480
     * It could be used when you have to change default or override current validate messages.
1481
     *
1482
     * @param array<string, string> $fieldMessages
1483
     *
1484
     * @return $this
1485
     */
1486
    public function setValidationMessage(string $field, array $fieldMessages)
1487
    {
1488
        $this->validationMessages[$field] = $fieldMessages;
2✔
1489

1490
        return $this;
2✔
1491
    }
1492

1493
    /**
1494
     * Allows to set (and reset) validation rules.
1495
     * It could be used when you have to change default or override current validate rules.
1496
     *
1497
     * @param array<string, array<string, array<string, string>|string>|string> $validationRules
1498
     *
1499
     * @return $this
1500
     */
1501
    public function setValidationRules(array $validationRules)
1502
    {
1503
        $this->validationRules = $validationRules;
2✔
1504

1505
        return $this;
2✔
1506
    }
1507

1508
    /**
1509
     * Allows to set field wise validation rules.
1510
     * It could be used when you have to change default or override current validate rules.
1511
     *
1512
     * @param array<string, array<string, string>|string>|string $fieldRules
1513
     *
1514
     * @return $this
1515
     */
1516
    public function setValidationRule(string $field, $fieldRules)
1517
    {
1518
        $rules = $this->validationRules;
2✔
1519

1520
        // ValidationRules can be either a string, which is the group name,
1521
        // or an array of rules.
1522
        if (is_string($rules)) {
2✔
1523
            $this->ensureValidation();
1✔
1524

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

1527
            $this->validationRules = $rules;
1✔
1528
            $this->validationMessages += $customErrors;
1✔
1529
        }
1530

1531
        $this->validationRules[$field] = $fieldRules;
2✔
1532

1533
        return $this;
2✔
1534
    }
1535

1536
    /**
1537
     * Should validation rules be removed before saving?
1538
     * Most handy when doing updates.
1539
     *
1540
     * @return $this
1541
     */
1542
    public function cleanRules(bool $choice = false)
1543
    {
1544
        $this->cleanValidationRules = $choice;
2✔
1545

1546
        return $this;
2✔
1547
    }
1548

1549
    /**
1550
     * Validate the row data against the validation rules (or the validation group)
1551
     * specified in the class property, $validationRules.
1552
     *
1553
     * @param object|row_array $row
1554
     */
1555
    public function validate($row): bool
1556
    {
1557
        if ($this->skipValidation) {
171✔
UNCOV
1558
            return true;
×
1559
        }
1560

1561
        $rules = $this->getValidationRules();
171✔
1562

1563
        if ($rules === []) {
171✔
1564
            return true;
125✔
1565
        }
1566

1567
        // Validation requires array, so cast away.
1568
        if (is_object($row)) {
46✔
1569
            $row = (array) $row;
2✔
1570
        }
1571

1572
        if ($row === []) {
46✔
UNCOV
1573
            return true;
×
1574
        }
1575

1576
        $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules;
46✔
1577

1578
        // If no data existed that needs validation
1579
        // our job is done here.
1580
        if ($rules === []) {
46✔
1581
            return true;
2✔
1582
        }
1583

1584
        $this->ensureValidation();
44✔
1585

1586
        $this->validation->reset()->setRules($rules, $this->validationMessages);
44✔
1587

1588
        return $this->validation->run($row, null, $this->DBGroup);
44✔
1589
    }
1590

1591
    /**
1592
     * Returns the model's defined validation rules so that they
1593
     * can be used elsewhere, if needed.
1594
     *
1595
     * @param array{only?: list<string>, except?: list<string>} $options Filter the list of rules
1596
     *
1597
     * @return array<string, array<string, array<string, string>|string>|string>
1598
     */
1599
    public function getValidationRules(array $options = []): array
1600
    {
1601
        $rules = $this->validationRules;
173✔
1602

1603
        // ValidationRules can be either a string, which is the group name,
1604
        // or an array of rules.
1605
        if (is_string($rules)) {
173✔
1606
            $this->ensureValidation();
13✔
1607

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

1610
            $this->validationMessages += $customErrors;
13✔
1611
        }
1612

1613
        if (isset($options['except'])) {
173✔
UNCOV
1614
            $rules = array_diff_key($rules, array_flip($options['except']));
×
1615
        } elseif (isset($options['only'])) {
173✔
UNCOV
1616
            $rules = array_intersect_key($rules, array_flip($options['only']));
×
1617
        }
1618

1619
        return $rules;
173✔
1620
    }
1621

1622
    protected function ensureValidation(): void
1623
    {
1624
        if ($this->validation === null) {
45✔
1625
            $this->validation = service('validation', null, false);
29✔
1626
        }
1627
    }
1628

1629
    /**
1630
     * Returns the model's validation messages, so they
1631
     * can be used elsewhere, if needed.
1632
     *
1633
     * @return array<string, array<string, string>>
1634
     */
1635
    public function getValidationMessages(): array
1636
    {
1637
        return $this->validationMessages;
2✔
1638
    }
1639

1640
    /**
1641
     * Removes any rules that apply to fields that have not been set
1642
     * currently so that rules don't block updating when only updating
1643
     * a partial row.
1644
     *
1645
     * @param array<string, array<string, array<string, string>|string>|string> $rules
1646
     * @param row_array                                                         $row
1647
     *
1648
     * @return array<string, array<string, array<string, string>|string>|string>
1649
     */
1650
    protected function cleanValidationRules(array $rules, array $row): array
1651
    {
1652
        if ($row === []) {
21✔
1653
            return [];
2✔
1654
        }
1655

1656
        foreach (array_keys($rules) as $field) {
19✔
1657
            if (! array_key_exists($field, $row)) {
19✔
1658
                unset($rules[$field]);
8✔
1659
            }
1660
        }
1661

1662
        return $rules;
19✔
1663
    }
1664

1665
    /**
1666
     * Sets $tempAllowCallbacks value so that we can temporarily override
1667
     * the setting. Resets after the next method that uses triggers.
1668
     *
1669
     * @return $this
1670
     */
1671
    public function allowCallbacks(bool $val = true)
1672
    {
1673
        $this->tempAllowCallbacks = $val;
3✔
1674

1675
        return $this;
3✔
1676
    }
1677

1678
    /**
1679
     * A simple event trigger for Model Events that allows additional
1680
     * data manipulation within the model. Specifically intended for
1681
     * usage by child models this can be used to format data,
1682
     * save/load related classes, etc.
1683
     *
1684
     * It is the responsibility of the callback methods to return
1685
     * the data itself.
1686
     *
1687
     * Each $eventData array MUST have a 'data' key with the relevant
1688
     * data for callback methods (like an array of key/value pairs to insert
1689
     * or update, an array of results, etc.)
1690
     *
1691
     * If callbacks are not allowed then returns $eventData immediately.
1692
     *
1693
     * @template TEventData of array<string, mixed>
1694
     *
1695
     * @param string     $event     Valid property of the model event: $this->before*, $this->after*, etc.
1696
     * @param TEventData $eventData
1697
     *
1698
     * @return TEventData
1699
     *
1700
     * @throws DataException
1701
     */
1702
    protected function trigger(string $event, array $eventData)
1703
    {
1704
        // Ensure it's a valid event
1705
        if (! isset($this->{$event}) || $this->{$event} === []) {
208✔
1706
            return $eventData;
193✔
1707
        }
1708

1709
        foreach ($this->{$event} as $callback) {
15✔
1710
            if (! method_exists($this, $callback)) {
15✔
1711
                throw DataException::forInvalidMethodTriggered($callback);
1✔
1712
            }
1713

1714
            $eventData = $this->{$callback}($eventData);
14✔
1715
        }
1716

1717
        return $eventData;
14✔
1718
    }
1719

1720
    /**
1721
     * Sets the return type of the results to be as an associative array.
1722
     *
1723
     * @return $this
1724
     */
1725
    public function asArray()
1726
    {
1727
        $this->tempReturnType = 'array';
31✔
1728

1729
        return $this;
31✔
1730
    }
1731

1732
    /**
1733
     * Sets the return type to be of the specified type of object.
1734
     * Defaults to a simple object, but can be any class that has
1735
     * class vars with the same name as the collection columns,
1736
     * or at least allows them to be created.
1737
     *
1738
     * @param 'object'|class-string $class
1739
     *
1740
     * @return $this
1741
     */
1742
    public function asObject(string $class = 'object')
1743
    {
1744
        $this->tempReturnType = $class;
18✔
1745

1746
        return $this;
18✔
1747
    }
1748

1749
    /**
1750
     * Takes a class and returns an array of its public and protected
1751
     * properties as an array suitable for use in creates and updates.
1752
     * This method uses `$this->objectToRawArray()` internally and does conversion
1753
     * to string on all Time instances.
1754
     *
1755
     * @param object $object
1756
     * @param bool   $onlyChanged Returns only the changed properties.
1757
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1758
     *
1759
     * @return array<string, mixed>
1760
     *
1761
     * @throws ReflectionException
1762
     */
1763
    protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
1764
    {
1765
        $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
24✔
1766

1767
        // Convert any Time instances to appropriate $dateFormat
1768
        return $this->timeToString($properties);
24✔
1769
    }
1770

1771
    /**
1772
     * Convert any Time instances to appropriate $dateFormat.
1773
     *
1774
     * @param array<string, mixed> $properties
1775
     *
1776
     * @return array<string, mixed>
1777
     */
1778
    protected function timeToString(array $properties): array
1779
    {
1780
        if ($properties === []) {
162✔
1781
            return [];
1✔
1782
        }
1783

1784
        return array_map(function ($value) {
161✔
1785
            if ($value instanceof Time) {
161✔
1786
                return $this->timeToDate($value);
5✔
1787
            }
1788

1789
            return $value;
161✔
1790
        }, $properties);
161✔
1791
    }
1792

1793
    /**
1794
     * Takes a class and returns an array of its public and protected
1795
     * properties as an array with raw values.
1796
     *
1797
     * @param object $object
1798
     * @param bool   $onlyChanged Returns only the changed properties.
1799
     * @param bool   $recursive   If `true`, inner entities will be cast as array as well.
1800
     *
1801
     * @return array<string, mixed> Array with raw values
1802
     *
1803
     * @throws ReflectionException
1804
     */
1805
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
1806
    {
1807
        // Entity::toRawArray() returns array
1808
        if (method_exists($object, 'toRawArray')) {
24✔
1809
            $properties = $object->toRawArray($onlyChanged, $recursive);
22✔
1810
        } else {
1811
            $mirror = new ReflectionClass($object);
2✔
1812
            $props  = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
2✔
1813

1814
            $properties = [];
2✔
1815

1816
            // Loop over each property,
1817
            // saving the name/value in a new array we can return
1818
            foreach ($props as $prop) {
2✔
1819
                $properties[$prop->getName()] = $prop->getValue($object);
2✔
1820
            }
1821
        }
1822

1823
        return $properties;
24✔
1824
    }
1825

1826
    /**
1827
     * Transform data to array.
1828
     *
1829
     * @param object|row_array|null $row
1830
     *
1831
     * @return array<int|string, mixed>
1832
     *
1833
     * @throws DataException
1834
     * @throws InvalidArgumentException
1835
     * @throws ReflectionException
1836
     *
1837
     * @used-by insert()
1838
     * @used-by insertBatch()
1839
     * @used-by update()
1840
     * @used-by updateBatch()
1841
     */
1842
    protected function transformDataToArray($row, string $type): array
1843
    {
1844
        if (! in_array($type, ['insert', 'update'], true)) {
166✔
1845
            throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
1✔
1846
        }
1847

1848
        if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
165✔
1849
            throw DataException::forEmptyDataset($type);
6✔
1850
        }
1851

1852
        // If it validates with entire rules, all fields are needed.
1853
        if ($this->skipValidation === false && $this->cleanValidationRules === false) {
162✔
1854
            $onlyChanged = false;
143✔
1855
        } else {
1856
            $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
48✔
1857
        }
1858

1859
        if ($this->useCasts()) {
162✔
1860
            if (is_array($row)) {
27✔
1861
                $row = $this->converter->toDataSource($row);
27✔
1862
            } elseif ($row instanceof stdClass) {
7✔
1863
                $row = (array) $row;
3✔
1864
                $row = $this->converter->toDataSource($row);
3✔
1865
            } elseif ($row instanceof Entity) {
4✔
1866
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1867
            } elseif (is_object($row)) {
2✔
1868
                $row = $this->converter->extract($row, $onlyChanged);
2✔
1869
            }
1870
        }
1871
        // If $row is using a custom class with public or protected
1872
        // properties representing the collection elements, we need to grab
1873
        // them as an array.
1874
        elseif (is_object($row) && ! $row instanceof stdClass) {
135✔
1875
            $row = $this->objectToArray($row, $onlyChanged, true);
23✔
1876
        }
1877

1878
        // If it's still a stdClass, go ahead and convert to
1879
        // an array so doProtectFields and other model methods
1880
        // don't have to do special checks.
1881
        if (is_object($row)) {
162✔
1882
            $row = (array) $row;
13✔
1883
        }
1884

1885
        // If it's still empty here, means $row is no change or is empty object
1886
        if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
162✔
UNCOV
1887
            throw DataException::forEmptyDataset($type);
×
1888
        }
1889

1890
        // Convert any Time instances to appropriate $dateFormat
1891
        return $this->timeToString($row);
162✔
1892
    }
1893

1894
    /**
1895
     * Provides the DB connection and model's properties.
1896
     *
1897
     * @return mixed
1898
     */
1899
    public function __get(string $name)
1900
    {
1901
        if (property_exists($this, $name)) {
50✔
1902
            return $this->{$name};
50✔
1903
        }
1904

1905
        return $this->db->{$name} ?? null;
1✔
1906
    }
1907

1908
    /**
1909
     * Checks for the existence of properties across this model, and DB connection.
1910
     */
1911
    public function __isset(string $name): bool
1912
    {
1913
        if (property_exists($this, $name)) {
50✔
1914
            return true;
50✔
1915
        }
1916

1917
        return isset($this->db->{$name});
1✔
1918
    }
1919

1920
    /**
1921
     * Provides direct access to method in the database connection.
1922
     *
1923
     * @param array<int|string, mixed> $params
1924
     *
1925
     * @return mixed
1926
     */
1927
    public function __call(string $name, array $params)
1928
    {
UNCOV
1929
        if (method_exists($this->db, $name)) {
×
UNCOV
1930
            return $this->db->{$name}(...$params);
×
1931
        }
1932

UNCOV
1933
        return null;
×
1934
    }
1935

1936
    /**
1937
     * Sets $allowEmptyInserts.
1938
     */
1939
    public function allowEmptyInserts(bool $value = true): self
1940
    {
1941
        $this->allowEmptyInserts = $value;
1✔
1942

1943
        return $this;
1✔
1944
    }
1945

1946
    /**
1947
     * Converts database data array to return type value.
1948
     *
1949
     * @param array<string, mixed>          $row        Raw data from database.
1950
     * @param 'array'|'object'|class-string $returnType
1951
     *
1952
     * @return array<string, mixed>|object
1953
     */
1954
    protected function convertToReturnType(array $row, string $returnType): array|object
1955
    {
1956
        if ($returnType === 'array') {
25✔
1957
            return $this->converter->fromDataSource($row);
10✔
1958
        }
1959

1960
        if ($returnType === 'object') {
17✔
1961
            return (object) $this->converter->fromDataSource($row);
5✔
1962
        }
1963

1964
        return $this->converter->reconstruct($returnType, $row);
12✔
1965
    }
1966
}
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