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

codeigniter4 / CodeIgniter4 / 26026282147

18 May 2026 09:53AM UTC coverage: 88.479% (+0.2%) from 88.299%
26026282147

Pull #10159

github

web-flow
Merge 45305d77b into 37b8b37b5
Pull Request #10159: feat: Add support for callable TTLs in cache handlers

6 of 10 new or added lines in 3 files covered. (60.0%)

452 existing lines in 24 files now uncovered.

24160 of 27306 relevant lines covered (88.48%)

219.61 hits per line

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

98.55
/system/Model.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\BaseBuilder;
18
use CodeIgniter\Database\BaseConnection;
19
use CodeIgniter\Database\ConnectionInterface;
20
use CodeIgniter\Database\Exceptions\DatabaseException;
21
use CodeIgniter\Database\Exceptions\DataException;
22
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
23
use CodeIgniter\Entity\Entity;
24
use CodeIgniter\Exceptions\BadMethodCallException;
25
use CodeIgniter\Exceptions\InvalidArgumentException;
26
use CodeIgniter\Exceptions\ModelException;
27
use CodeIgniter\Validation\ValidationInterface;
28
use Config\Database;
29
use Config\Feature;
30
use Generator;
31
use stdClass;
32

33
/**
34
 * The Model class extends BaseModel and provides additional
35
 * convenient features that makes working with a SQL database
36
 * table less painful.
37
 *
38
 * It will:
39
 *      - automatically connect to database
40
 *      - allow intermingling calls to the builder
41
 *      - removes the need to use Result object directly in most cases
42
 *
43
 * @property-read BaseConnection $db
44
 *
45
 * @method $this groupBy($by, ?bool $escape = null)
46
 * @method $this groupEnd()
47
 * @method $this groupStart()
48
 * @method $this having($key, $value = null, ?bool $escape = null)
49
 * @method $this havingGroupEnd()
50
 * @method $this havingGroupStart()
51
 * @method $this havingIn(?string $key = null, $values = null, ?bool $escape = null)
52
 * @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
53
 * @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
54
 * @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
55
 * @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
56
 * @method $this limit(?int $value = null, ?int $offset = 0)
57
 * @method $this notGroupStart()
58
 * @method $this notHavingGroupStart()
59
 * @method $this notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
60
 * @method $this notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
61
 * @method $this offset(int $offset)
62
 * @method $this orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
63
 * @method $this orGroupStart()
64
 * @method $this orHaving($key, $value = null, ?bool $escape = null)
65
 * @method $this orHavingGroupStart()
66
 * @method $this orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
67
 * @method $this orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
68
 * @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
69
 * @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
70
 * @method $this orNotGroupStart()
71
 * @method $this orNotHavingGroupStart()
72
 * @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
73
 * @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
74
 * @method $this orWhere($key, $value = null, ?bool $escape = null)
75
 * @method $this orWhereBetween(?string $key = null, $values = null, ?bool $escape = null)
76
 * @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
77
 * @method $this orWhereExists($subquery)
78
 * @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
79
 * @method $this orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
80
 * @method $this orWhereNotExists($subquery)
81
 * @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
82
 * @method $this select($select = '*', ?bool $escape = null)
83
 * @method $this selectAvg(string $select = '', string $alias = '')
84
 * @method $this selectCount(string $select = '', string $alias = '')
85
 * @method $this selectMax(string $select = '', string $alias = '')
86
 * @method $this selectMin(string $select = '', string $alias = '')
87
 * @method $this selectSum(string $select = '', string $alias = '')
88
 * @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
89
 * @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
90
 * @method $this where($key, $value = null, ?bool $escape = null)
91
 * @method $this whereBetween(?string $key = null, $values = null, ?bool $escape = null)
92
 * @method $this whereColumn(string $first, string $second, ?bool $escape = null)
93
 * @method $this whereExists($subquery)
94
 * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
95
 * @method $this whereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
96
 * @method $this whereNotExists($subquery)
97
 * @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
98
 *
99
 * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
100
 * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
101
 * @phpstan-import-type row_array from BaseModel
102
 */
103
class Model extends BaseModel
104
{
105
    /**
106
     * Name of database table.
107
     *
108
     * @var string
109
     */
110
    protected $table;
111

112
    /**
113
     * The table's primary key.
114
     *
115
     * @var string
116
     */
117
    protected $primaryKey = 'id';
118

119
    /**
120
     * Whether primary key uses auto increment.
121
     *
122
     * @var bool
123
     */
124
    protected $useAutoIncrement = true;
125

126
    /**
127
     * Query Builder object.
128
     *
129
     * @var BaseBuilder|null
130
     */
131
    protected $builder;
132

133
    /**
134
     * Holds information passed in via 'set'
135
     * so that we can capture it (not the builder)
136
     * and ensure it gets validated first.
137
     *
138
     * @var array{escape: array<int|string, bool|null>, data: row_array}|array{}
139
     */
140
    protected $tempData = [];
141

142
    /**
143
     * Escape array that maps usage of escape
144
     * flag for every parameter.
145
     *
146
     * @var array<int|string, bool|null>
147
     */
148
    protected $escape = [];
149

150
    /**
151
     * Builder method names that should not be used in the Model.
152
     *
153
     * @var list<string>
154
     */
155
    private array $builderMethodsNotAvailable = [
156
        'getCompiledInsert',
157
        'getCompiledSelect',
158
        'getCompiledUpdate',
159
    ];
160

161
    public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
162
    {
163
        /** @var BaseConnection $db */
164
        $db ??= Database::connect($this->DBGroup);
416✔
165

166
        $this->db = $db;
416✔
167

168
        parent::__construct($validation);
416✔
169
    }
170

171
    /**
172
     * Specify the table associated with a model.
173
     *
174
     * @return $this
175
     */
176
    public function setTable(string $table)
177
    {
178
        $this->table = $table;
1✔
179

180
        return $this;
1✔
181
    }
182

183
    protected function doFind(bool $singleton, $id = null)
184
    {
185
        $builder = $this->builder();
56✔
186
        $useCast = $this->useCasts();
55✔
187

188
        if ($useCast) {
55✔
189
            $returnType = $this->tempReturnType;
18✔
190
            $this->asArray();
18✔
191
        }
192

193
        if ($this->tempUseSoftDeletes) {
55✔
194
            $builder->where($this->table . '.' . $this->deletedField, null);
31✔
195
        }
196

197
        $row  = null;
55✔
198
        $rows = [];
55✔
199

200
        if (is_array($id)) {
55✔
201
            $rows = $builder->whereIn($this->table . '.' . $this->primaryKey, $id)
3✔
202
                ->get()
3✔
203
                ->getResult($this->tempReturnType);
3✔
204
        } elseif ($singleton) {
52✔
205
            $row = $builder->where($this->table . '.' . $this->primaryKey, $id)
44✔
206
                ->get()
44✔
207
                ->getFirstRow($this->tempReturnType);
44✔
208
        } else {
209
            $rows = $builder->get()->getResult($this->tempReturnType);
9✔
210
        }
211

212
        if ($useCast) {
55✔
213
            $this->tempReturnType = $returnType;
18✔
214

215
            if ($singleton) {
18✔
216
                if ($row === null) {
15✔
217
                    return null;
1✔
218
                }
219

220
                return $this->convertToReturnType($row, $returnType);
14✔
221
            }
222

223
            foreach ($rows as $i => $row) {
3✔
224
                $rows[$i] = $this->convertToReturnType($row, $returnType);
3✔
225
            }
226

227
            return $rows;
3✔
228
        }
229

230
        if ($singleton) {
37✔
231
            return $row;
29✔
232
        }
233

234
        return $rows;
9✔
235
    }
236

237
    protected function doFindColumn(string $columnName)
238
    {
239
        return $this->select($columnName)->asArray()->find();
2✔
240
    }
241

242
    /**
243
     * {@inheritDoc}
244
     *
245
     * Works with the current Query Builder instance.
246
     */
247
    protected function doFindAll(?int $limit = null, int $offset = 0)
248
    {
249
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
22✔
250
        if ($limitZeroAsAll) {
22✔
251
            $limit ??= 0;
22✔
252
        }
253

254
        $builder = $this->builder();
22✔
255

256
        $useCast = $this->useCasts();
22✔
257
        if ($useCast) {
22✔
258
            $returnType = $this->tempReturnType;
5✔
259
            $this->asArray();
5✔
260
        }
261

262
        if ($this->tempUseSoftDeletes) {
22✔
263
            $builder->where($this->table . '.' . $this->deletedField, null);
10✔
264
        }
265

266
        $results = $builder->limit($limit, $offset)
22✔
267
            ->get()
22✔
268
            ->getResult($this->tempReturnType);
22✔
269

270
        if ($useCast) {
22✔
271
            foreach ($results as $i => $row) {
5✔
272
                $results[$i] = $this->convertToReturnType($row, $returnType);
4✔
273
            }
274

275
            $this->tempReturnType = $returnType;
5✔
276
        }
277

278
        return $results;
22✔
279
    }
280

281
    /**
282
     * {@inheritDoc}
283
     *
284
     * Will take any previous Query Builder calls into account
285
     * when determining the result set.
286
     */
287
    protected function doFirst()
288
    {
289
        $builder = $this->builder();
36✔
290

291
        $useCast = $this->useCasts();
36✔
292
        if ($useCast) {
36✔
293
            $returnType = $this->tempReturnType;
5✔
294
            $this->asArray();
5✔
295
        }
296

297
        if ($this->tempUseSoftDeletes) {
36✔
298
            $builder->where($this->table . '.' . $this->deletedField, null);
31✔
299
        } elseif ($this->useSoftDeletes && ($builder->QBGroupBy === []) && $this->primaryKey !== '') {
13✔
300
            $builder->groupBy($this->table . '.' . $this->primaryKey);
6✔
301
        }
302

303
        // Some databases, like PostgreSQL, need order
304
        // information to consistently return correct results.
305
        if ($builder->QBGroupBy !== [] && ($builder->QBOrderBy === []) && $this->primaryKey !== '') {
36✔
306
            $builder->orderBy($this->table . '.' . $this->primaryKey, 'asc');
9✔
307
        }
308

309
        $row = $builder->limit(1, 0)->get()->getFirstRow($this->tempReturnType);
36✔
310

311
        if ($useCast && $row !== null) {
36✔
312
            $row = $this->convertToReturnType($row, $returnType);
4✔
313

314
            $this->tempReturnType = $returnType;
4✔
315
        }
316

317
        return $row;
36✔
318
    }
319

320
    protected function doInsert(array $row)
321
    {
322
        $escape       = $this->escape;
102✔
323
        $this->escape = [];
102✔
324

325
        // Require non-empty primaryKey when
326
        // not using auto-increment feature
327
        if (! $this->useAutoIncrement) {
102✔
328
            if (! isset($row[$this->primaryKey])) {
15✔
329
                throw DataException::forEmptyPrimaryKey('insert');
2✔
330
            }
331

332
            // Validate the primary key value (arrays not allowed for insert)
333
            $this->validateID($row[$this->primaryKey], false);
13✔
334
        }
335

336
        $builder = $this->builder();
91✔
337

338
        // Must use the set() method to ensure to set the correct escape flag
339
        foreach ($row as $key => $val) {
91✔
340
            $builder->set($key, $val, $escape[$key] ?? null);
90✔
341
        }
342

343
        if ($this->allowEmptyInserts && $row === []) {
91✔
344
            $table = $this->db->protectIdentifiers($this->table, true, null, false);
1✔
345
            if ($this->db->getPlatform() === 'MySQLi') {
1✔
346
                $sql = 'INSERT INTO ' . $table . ' VALUES ()';
1✔
347
            } elseif ($this->db->getPlatform() === 'OCI8') {
1✔
348
                $allFields = $this->db->protectIdentifiers(
1✔
349
                    array_map(
1✔
350
                        static fn ($row) => $row->name,
1✔
351
                        $this->db->getFieldData($this->table),
1✔
352
                    ),
1✔
353
                    false,
1✔
354
                    true,
1✔
355
                );
1✔
356

357
                $sql = sprintf(
1✔
358
                    'INSERT INTO %s (%s) VALUES (%s)',
1✔
359
                    $table,
1✔
360
                    implode(',', $allFields),
1✔
361
                    substr(str_repeat(',DEFAULT', count($allFields)), 1),
1✔
362
                );
1✔
363
            } else {
364
                $sql = 'INSERT INTO ' . $table . ' DEFAULT VALUES';
1✔
365
            }
366

367
            $result = $this->db->query($sql);
1✔
368
        } else {
369
            $result = $builder->insert();
90✔
370
        }
371

372
        // If insertion succeeded then save the insert ID
373
        if ($result) {
91✔
374
            $this->insertID = $this->useAutoIncrement ? $this->db->insertID() : $row[$this->primaryKey];
88✔
375
        }
376

377
        return $result;
91✔
378
    }
379

380
    protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false)
381
    {
382
        if (is_array($set) && ! $this->useAutoIncrement) {
21✔
383
            foreach ($set as $row) {
11✔
384
                // Require non-empty $primaryKey when
385
                // not using auto-increment feature
386
                if (! isset($row[$this->primaryKey])) {
11✔
387
                    throw DataException::forEmptyPrimaryKey('insertBatch');
1✔
388
                }
389

390
                // Validate the primary key value
391
                $this->validateID($row[$this->primaryKey], false);
11✔
392
            }
393
        }
394

395
        return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize);
11✔
396
    }
397

398
    protected function doUpdate($id = null, $row = null): bool
399
    {
400
        $escape       = $this->escape;
38✔
401
        $this->escape = [];
38✔
402

403
        $builder = $this->builder();
38✔
404

405
        if (is_array($id) && $id !== []) {
38✔
406
            $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
32✔
407
        }
408

409
        // Must use the set() method to ensure to set the correct escape flag
410
        foreach ($row as $key => $val) {
38✔
411
            $builder->set($key, $val, $escape[$key] ?? null);
38✔
412
        }
413

414
        if ($builder->getCompiledQBWhere() === []) {
38✔
415
            throw new DatabaseException(
1✔
416
                'Updates are not allowed unless they contain a "where" or "like" clause.',
1✔
417
            );
1✔
418
        }
419

420
        return $builder->update();
37✔
421
    }
422

423
    protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
424
    {
425
        return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize);
5✔
426
    }
427

428
    protected function doDelete($id = null, bool $purge = false)
429
    {
430
        $set     = [];
36✔
431
        $builder = $this->builder();
36✔
432

433
        if (is_array($id) && $id !== []) {
36✔
434
            $builder = $builder->whereIn($this->primaryKey, $id);
22✔
435
        }
436

437
        if ($this->useSoftDeletes && ! $purge) {
36✔
438
            if ($builder->getCompiledQBWhere() === []) {
25✔
439
                throw new DatabaseException(
3✔
440
                    'Deletes are not allowed unless they contain a "where" or "like" clause.',
3✔
441
                );
3✔
442
            }
443

444
            $builder->where($this->deletedField);
22✔
445

446
            $set[$this->deletedField] = $this->setDate();
22✔
447

448
            if ($this->useTimestamps && $this->updatedField !== '') {
21✔
449
                $set[$this->updatedField] = $this->setDate();
1✔
450
            }
451

452
            return $builder->update($set);
21✔
453
        }
454

455
        return $builder->delete();
11✔
456
    }
457

458
    protected function doPurgeDeleted()
459
    {
460
        return $this->builder()
1✔
461
            ->where($this->table . '.' . $this->deletedField . ' IS NOT NULL')
1✔
462
            ->delete();
1✔
463
    }
464

465
    protected function doOnlyDeleted()
466
    {
467
        $this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL');
1✔
468
    }
469

470
    protected function doReplace(?array $row = null, bool $returnSQL = false)
471
    {
472
        return $this->builder()->testMode($returnSQL)->replace($row);
2✔
473
    }
474

475
    /**
476
     * {@inheritDoc}
477
     *
478
     * The return array should be in the following format:
479
     *  `['source' => 'message']`.
480
     * This method works only with dbCalls.
481
     */
482
    protected function doErrors()
483
    {
484
        // $error is always ['code' => string|int, 'message' => string]
485
        $error = $this->db->error();
2✔
486

487
        if ((int) $error['code'] === 0) {
2✔
488
            return [];
2✔
489
        }
490

UNCOV
491
        return [$this->db::class => $error['message']];
×
492
    }
493

494
    public function getIdValue($row)
495
    {
496
        if (is_object($row)) {
27✔
497
            // Get the raw or mapped primary key value of the Entity.
498
            if ($row instanceof Entity && $row->{$this->primaryKey} !== null) {
17✔
499
                $cast = $row->cast();
9✔
500

501
                // Disable Entity casting, because raw primary key value is needed for database.
502
                $row->cast(false);
9✔
503

504
                $primaryKey = $row->{$this->primaryKey};
9✔
505

506
                // Restore Entity casting setting.
507
                $row->cast($cast);
9✔
508

509
                return $primaryKey;
9✔
510
            }
511

512
            if (! $row instanceof Entity && isset($row->{$this->primaryKey})) {
9✔
513
                return $row->{$this->primaryKey};
5✔
514
            }
515
        }
516

517
        if (is_array($row) && isset($row[$this->primaryKey])) {
15✔
518
            return $row[$this->primaryKey];
4✔
519
        }
520

521
        return null;
11✔
522
    }
523

524
    public function countAllResults(bool $reset = true, bool $test = false)
525
    {
526
        if ($this->tempUseSoftDeletes) {
17✔
527
            $this->builder()->where($this->table . '.' . $this->deletedField, null);
6✔
528
        }
529

530
        // When $reset === false, the $tempUseSoftDeletes will be
531
        // dependent on $useSoftDeletes value because we don't
532
        // want to add the same "where" condition for the second time.
533
        $this->tempUseSoftDeletes = $reset
17✔
534
            ? $this->useSoftDeletes
10✔
535
            : ($this->useSoftDeletes ? false : $this->useSoftDeletes);
12✔
536

537
        return $this->builder()->testMode($test)->countAllResults($reset);
17✔
538
    }
539

540
    /**
541
     * Iterates over the result set in chunks of the specified size.
542
     *
543
     * @param int $size The number of records to retrieve in each chunk.
544
     *
545
     * @return Generator<list<array<string, string>>|list<object>>
546
     */
547
    private function iterateChunks(int $size): Generator
548
    {
549
        if ($size <= 0) {
12✔
550
            throw new InvalidArgumentException('$size must be a positive integer.');
4✔
551
        }
552

553
        $total  = $this->builder()->countAllResults(false);
8✔
554
        $offset = 0;
8✔
555

556
        while ($offset < $total) {
8✔
557
            $builder = clone $this->builder();
6✔
558
            $rows    = $builder->get($size, $offset);
6✔
559

560
            if (! $rows) {
6✔
UNCOV
561
                throw DataException::forEmptyDataset('chunk');
×
562
            }
563

564
            $rows = $rows->getResult($this->tempReturnType);
6✔
565

566
            $offset += $size;
6✔
567

568
            if ($rows === []) {
6✔
UNCOV
569
                continue;
×
570
            }
571

572
            yield $rows;
6✔
573
        }
574
    }
575

576
    /**
577
     * {@inheritDoc}
578
     */
579
    public function chunk(int $size, Closure $userFunc)
580
    {
581
        foreach ($this->iterateChunks($size) as $rows) {
6✔
582
            foreach ($rows as $row) {
3✔
583
                if ($userFunc($row) === false) {
3✔
584
                    return;
1✔
585
                }
586
            }
587
        }
588
    }
589

590
    /**
591
     * {@inheritDoc}
592
     */
593
    public function chunkRows(int $size, Closure $userFunc): void
594
    {
595
        foreach ($this->iterateChunks($size) as $rows) {
6✔
596
            if ($userFunc($rows) === false) {
3✔
597
                return;
1✔
598
            }
599
        }
600
    }
601

602
    /**
603
     * Provides a shared instance of the Query Builder.
604
     *
605
     * @param non-empty-string|null $table
606
     *
607
     * @return BaseBuilder
608
     *
609
     * @throws ModelException
610
     */
611
    public function builder(?string $table = null)
612
    {
613
        // Check for an existing Builder
614
        if ($this->builder instanceof BaseBuilder) {
217✔
615
            // Make sure the requested table matches the builder
616
            if ((string) $table !== '' && $this->builder->getTable() !== $table) {
133✔
617
                return $this->db->table($table);
1✔
618
            }
619

620
            return $this->builder;
133✔
621
        }
622

623
        // We're going to force a primary key to exist
624
        // so we don't have overly convoluted code,
625
        // and future features are likely to require them.
626
        if ($this->primaryKey === '') {
217✔
627
            throw ModelException::forNoPrimaryKey(static::class);
1✔
628
        }
629

630
        $table = ((string) $table === '') ? $this->table : $table;
216✔
631

632
        // Ensure we have a good db connection
633
        if (! $this->db instanceof BaseConnection) {
216✔
UNCOV
634
            $this->db = Database::connect($this->DBGroup);
×
635
        }
636

637
        $builder = $this->db->table($table);
216✔
638

639
        // Only consider it "shared" if the table is correct
640
        if ($table === $this->table) {
216✔
641
            $this->builder = $builder;
216✔
642
        }
643

644
        return $builder;
216✔
645
    }
646

647
    /**
648
     * Captures the builder's set() method so that we can validate the
649
     * data here. This allows it to be used with any of the other
650
     * builder methods and still get validated data, like replace.
651
     *
652
     * @param object|row_array|string           $key    Field name, or an array of field/value pairs, or an object
653
     * @param bool|float|int|object|string|null $value  Field value, if $key is a single field
654
     * @param bool|null                         $escape Whether to escape values
655
     *
656
     * @return $this
657
     */
658
    public function set($key, $value = '', ?bool $escape = null)
659
    {
660
        if (is_object($key)) {
10✔
661
            $key = $key instanceof stdClass ? (array) $key : $this->objectToArray($key);
2✔
662
        }
663

664
        $data = is_array($key) ? $key : [$key => $value];
10✔
665

666
        foreach (array_keys($data) as $k) {
10✔
667
            $this->tempData['escape'][$k] = $escape;
10✔
668
        }
669

670
        $this->tempData['data'] = array_merge($this->tempData['data'] ?? [], $data);
10✔
671

672
        return $this;
10✔
673
    }
674

675
    protected function shouldUpdate($row): bool
676
    {
677
        if (parent::shouldUpdate($row) === false) {
27✔
678
            return false;
12✔
679
        }
680

681
        if ($this->useAutoIncrement === true) {
17✔
682
            return true;
14✔
683
        }
684

685
        // When useAutoIncrement feature is disabled, check
686
        // in the database if given record already exists
687
        return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1;
3✔
688
    }
689

690
    public function insert($row = null, bool $returnID = true)
691
    {
692
        if (isset($this->tempData['data'])) {
127✔
693
            if ($row === null) {
2✔
694
                $row = $this->tempData['data'];
1✔
695
            } else {
696
                $row = $this->transformDataToArray($row, 'insert');
1✔
697
                $row = array_merge($this->tempData['data'], $row);
1✔
698
            }
699
        }
700

701
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
702
        $this->tempData = [];
127✔
703

704
        return parent::insert($row, $returnID);
127✔
705
    }
706

707
    protected function doProtectFieldsForInsert(array $row): array
708
    {
709
        if (! $this->protectFields) {
128✔
710
            return $row;
9✔
711
        }
712

713
        if ($this->allowedFields === []) {
119✔
714
            throw DataException::forInvalidAllowedFields(static::class);
1✔
715
        }
716

717
        foreach (array_keys($row) as $key) {
118✔
718
            // Do not remove the non-auto-incrementing primary key data.
719
            if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
117✔
720
                continue;
25✔
721
            }
722

723
            if (! in_array($key, $this->allowedFields, true)) {
117✔
724
                unset($row[$key]);
25✔
725
            }
726
        }
727

728
        return $row;
118✔
729
    }
730

731
    /**
732
     * Finds the first row matching attributes or inserts a new row.
733
     *
734
     * Note: without a DB unique constraint, this is not race-safe.
735
     *
736
     * @param array<string, mixed>|object $attributes
737
     * @param array<string, mixed>|object $values
738
     *
739
     * @return array<string, mixed>|false|object
740
     */
741
    public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object
742
    {
743
        if (is_object($attributes)) {
13✔
744
            $attributes = $this->transformDataToArray($attributes, 'insert');
2✔
745
        }
746

747
        if ($attributes === []) {
13✔
748
            throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
1✔
749
        }
750

751
        $row = $this->where($attributes)->first();
12✔
752
        if ($row !== null) {
12✔
753
            return $row;
4✔
754
        }
755

756
        if (is_object($values)) {
8✔
757
            $values = $this->transformDataToArray($values, 'insert');
1✔
758
        }
759

760
        $data = array_merge($attributes, $values);
8✔
761

762
        try {
763
            $id = $this->insert($data);
8✔
764
        } catch (UniqueConstraintViolationException) {
1✔
765
            return $this->where($attributes)->first() ?? false;
1✔
766
        }
767

768
        if ($id === false) {
7✔
769
            if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
3✔
770
                return $this->where($attributes)->first() ?? false;
1✔
771
            }
772

773
            return false;
2✔
774
        }
775

776
        return $this->where($this->primaryKey, $id)->first() ?? false;
4✔
777
    }
778

779
    public function update($id = null, $row = null): bool
780
    {
781
        if (isset($this->tempData['data'])) {
58✔
782
            if ($row === null) {
6✔
783
                $row = $this->tempData['data'];
5✔
784
            } else {
785
                $row = $this->transformDataToArray($row, 'update');
1✔
786
                $row = array_merge($this->tempData['data'], $row);
1✔
787
            }
788
        }
789

790
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
791
        $this->tempData = [];
58✔
792

793
        return parent::update($id, $row);
58✔
794
    }
795

796
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
797
    {
798
        return parent::objectToRawArray($object, $onlyChanged);
25✔
799
    }
800

801
    /**
802
     * Provides/instantiates the builder/db connection and model's table/primary key names and return type.
803
     *
804
     * @return array<int|string, mixed>|BaseBuilder|bool|float|int|object|string|null
805
     */
806
    public function __get(string $name)
807
    {
808
        if (parent::__isset($name)) {
50✔
809
            return parent::__get($name);
50✔
810
        }
811

812
        return $this->builder()->{$name} ?? null;
1✔
813
    }
814

815
    /**
816
     * Checks for the existence of properties across this model, builder, and db connection.
817
     */
818
    public function __isset(string $name): bool
819
    {
820
        if (parent::__isset($name)) {
47✔
821
            return true;
47✔
822
        }
823

824
        return isset($this->builder()->{$name});
1✔
825
    }
826

827
    /**
828
     * Provides direct access to method in the builder (if available)
829
     * and the database connection.
830
     *
831
     * @return $this|array<int|string, mixed>|BaseBuilder|bool|float|int|object|string|null
832
     */
833
    public function __call(string $name, array $params)
834
    {
835
        $builder = $this->builder();
60✔
836
        $result  = null;
60✔
837

838
        if (method_exists($this->db, $name)) {
60✔
839
            $result = $this->db->{$name}(...$params);
2✔
840
        } elseif (method_exists($builder, $name)) {
59✔
841
            $this->checkBuilderMethod($name);
58✔
842

843
            $result = $builder->{$name}(...$params);
56✔
844
        } else {
845
            throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
1✔
846
        }
847

848
        if ($result instanceof BaseBuilder) {
57✔
849
            return $this;
56✔
850
        }
851

852
        return $result;
2✔
853
    }
854

855
    /**
856
     * Checks the Builder method name that should not be used in the Model.
857
     */
858
    private function checkBuilderMethod(string $name): void
859
    {
860
        if (in_array($name, $this->builderMethodsNotAvailable, true)) {
58✔
861
            throw ModelException::forMethodNotAvailable(static::class, $name . '()');
2✔
862
        }
863
    }
864
}
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