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

codeigniter4 / CodeIgniter4 / 25902734269

15 May 2026 05:51AM UTC coverage: 88.459% (+0.2%) from 88.299%
25902734269

Pull #10159

github

web-flow
Merge f0573f3e0 into 170b89a6e
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%)

446 existing lines in 24 files now uncovered.

24114 of 27260 relevant lines covered (88.46%)

219.07 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 orWhereColumn(string $first, string $second, ?bool $escape = null)
76
 * @method $this orWhereExists($subquery)
77
 * @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
78
 * @method $this orWhereNotExists($subquery)
79
 * @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
80
 * @method $this select($select = '*', ?bool $escape = null)
81
 * @method $this selectAvg(string $select = '', string $alias = '')
82
 * @method $this selectCount(string $select = '', string $alias = '')
83
 * @method $this selectMax(string $select = '', string $alias = '')
84
 * @method $this selectMin(string $select = '', string $alias = '')
85
 * @method $this selectSum(string $select = '', string $alias = '')
86
 * @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
87
 * @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
88
 * @method $this where($key, $value = null, ?bool $escape = null)
89
 * @method $this whereColumn(string $first, string $second, ?bool $escape = null)
90
 * @method $this whereExists($subquery)
91
 * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
92
 * @method $this whereNotExists($subquery)
93
 * @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
94
 *
95
 * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
96
 * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
97
 * @phpstan-import-type row_array from BaseModel
98
 */
99
class Model extends BaseModel
100
{
101
    /**
102
     * Name of database table.
103
     *
104
     * @var string
105
     */
106
    protected $table;
107

108
    /**
109
     * The table's primary key.
110
     *
111
     * @var string
112
     */
113
    protected $primaryKey = 'id';
114

115
    /**
116
     * Whether primary key uses auto increment.
117
     *
118
     * @var bool
119
     */
120
    protected $useAutoIncrement = true;
121

122
    /**
123
     * Query Builder object.
124
     *
125
     * @var BaseBuilder|null
126
     */
127
    protected $builder;
128

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

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

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

157
    public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
158
    {
159
        /** @var BaseConnection $db */
160
        $db ??= Database::connect($this->DBGroup);
416✔
161

162
        $this->db = $db;
416✔
163

164
        parent::__construct($validation);
416✔
165
    }
166

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

176
        return $this;
1✔
177
    }
178

179
    protected function doFind(bool $singleton, $id = null)
180
    {
181
        $builder = $this->builder();
56✔
182
        $useCast = $this->useCasts();
55✔
183

184
        if ($useCast) {
55✔
185
            $returnType = $this->tempReturnType;
18✔
186
            $this->asArray();
18✔
187
        }
188

189
        if ($this->tempUseSoftDeletes) {
55✔
190
            $builder->where($this->table . '.' . $this->deletedField, null);
31✔
191
        }
192

193
        $row  = null;
55✔
194
        $rows = [];
55✔
195

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

208
        if ($useCast) {
55✔
209
            $this->tempReturnType = $returnType;
18✔
210

211
            if ($singleton) {
18✔
212
                if ($row === null) {
15✔
213
                    return null;
1✔
214
                }
215

216
                return $this->convertToReturnType($row, $returnType);
14✔
217
            }
218

219
            foreach ($rows as $i => $row) {
3✔
220
                $rows[$i] = $this->convertToReturnType($row, $returnType);
3✔
221
            }
222

223
            return $rows;
3✔
224
        }
225

226
        if ($singleton) {
37✔
227
            return $row;
29✔
228
        }
229

230
        return $rows;
9✔
231
    }
232

233
    protected function doFindColumn(string $columnName)
234
    {
235
        return $this->select($columnName)->asArray()->find();
2✔
236
    }
237

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

250
        $builder = $this->builder();
22✔
251

252
        $useCast = $this->useCasts();
22✔
253
        if ($useCast) {
22✔
254
            $returnType = $this->tempReturnType;
5✔
255
            $this->asArray();
5✔
256
        }
257

258
        if ($this->tempUseSoftDeletes) {
22✔
259
            $builder->where($this->table . '.' . $this->deletedField, null);
10✔
260
        }
261

262
        $results = $builder->limit($limit, $offset)
22✔
263
            ->get()
22✔
264
            ->getResult($this->tempReturnType);
22✔
265

266
        if ($useCast) {
22✔
267
            foreach ($results as $i => $row) {
5✔
268
                $results[$i] = $this->convertToReturnType($row, $returnType);
4✔
269
            }
270

271
            $this->tempReturnType = $returnType;
5✔
272
        }
273

274
        return $results;
22✔
275
    }
276

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

287
        $useCast = $this->useCasts();
36✔
288
        if ($useCast) {
36✔
289
            $returnType = $this->tempReturnType;
5✔
290
            $this->asArray();
5✔
291
        }
292

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

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

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

307
        if ($useCast && $row !== null) {
36✔
308
            $row = $this->convertToReturnType($row, $returnType);
4✔
309

310
            $this->tempReturnType = $returnType;
4✔
311
        }
312

313
        return $row;
36✔
314
    }
315

316
    protected function doInsert(array $row)
317
    {
318
        $escape       = $this->escape;
102✔
319
        $this->escape = [];
102✔
320

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

328
            // Validate the primary key value (arrays not allowed for insert)
329
            $this->validateID($row[$this->primaryKey], false);
13✔
330
        }
331

332
        $builder = $this->builder();
91✔
333

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

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

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

363
            $result = $this->db->query($sql);
1✔
364
        } else {
365
            $result = $builder->insert();
90✔
366
        }
367

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

373
        return $result;
91✔
374
    }
375

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

386
                // Validate the primary key value
387
                $this->validateID($row[$this->primaryKey], false);
11✔
388
            }
389
        }
390

391
        return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize);
11✔
392
    }
393

394
    protected function doUpdate($id = null, $row = null): bool
395
    {
396
        $escape       = $this->escape;
38✔
397
        $this->escape = [];
38✔
398

399
        $builder = $this->builder();
38✔
400

401
        if (is_array($id) && $id !== []) {
38✔
402
            $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
32✔
403
        }
404

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

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

416
        return $builder->update();
37✔
417
    }
418

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

424
    protected function doDelete($id = null, bool $purge = false)
425
    {
426
        $set     = [];
36✔
427
        $builder = $this->builder();
36✔
428

429
        if (is_array($id) && $id !== []) {
36✔
430
            $builder = $builder->whereIn($this->primaryKey, $id);
22✔
431
        }
432

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

440
            $builder->where($this->deletedField);
22✔
441

442
            $set[$this->deletedField] = $this->setDate();
22✔
443

444
            if ($this->useTimestamps && $this->updatedField !== '') {
21✔
445
                $set[$this->updatedField] = $this->setDate();
1✔
446
            }
447

448
            return $builder->update($set);
21✔
449
        }
450

451
        return $builder->delete();
11✔
452
    }
453

454
    protected function doPurgeDeleted()
455
    {
456
        return $this->builder()
1✔
457
            ->where($this->table . '.' . $this->deletedField . ' IS NOT NULL')
1✔
458
            ->delete();
1✔
459
    }
460

461
    protected function doOnlyDeleted()
462
    {
463
        $this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL');
1✔
464
    }
465

466
    protected function doReplace(?array $row = null, bool $returnSQL = false)
467
    {
468
        return $this->builder()->testMode($returnSQL)->replace($row);
2✔
469
    }
470

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

483
        if ((int) $error['code'] === 0) {
2✔
484
            return [];
2✔
485
        }
486

UNCOV
487
        return [$this->db::class => $error['message']];
×
488
    }
489

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

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

500
                $primaryKey = $row->{$this->primaryKey};
9✔
501

502
                // Restore Entity casting setting.
503
                $row->cast($cast);
9✔
504

505
                return $primaryKey;
9✔
506
            }
507

508
            if (! $row instanceof Entity && isset($row->{$this->primaryKey})) {
9✔
509
                return $row->{$this->primaryKey};
5✔
510
            }
511
        }
512

513
        if (is_array($row) && isset($row[$this->primaryKey])) {
15✔
514
            return $row[$this->primaryKey];
4✔
515
        }
516

517
        return null;
11✔
518
    }
519

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

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

533
        return $this->builder()->testMode($test)->countAllResults($reset);
17✔
534
    }
535

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

549
        $total  = $this->builder()->countAllResults(false);
8✔
550
        $offset = 0;
8✔
551

552
        while ($offset < $total) {
8✔
553
            $builder = clone $this->builder();
6✔
554
            $rows    = $builder->get($size, $offset);
6✔
555

556
            if (! $rows) {
6✔
UNCOV
557
                throw DataException::forEmptyDataset('chunk');
×
558
            }
559

560
            $rows = $rows->getResult($this->tempReturnType);
6✔
561

562
            $offset += $size;
6✔
563

564
            if ($rows === []) {
6✔
UNCOV
565
                continue;
×
566
            }
567

568
            yield $rows;
6✔
569
        }
570
    }
571

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

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

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

616
            return $this->builder;
133✔
617
        }
618

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

626
        $table = ((string) $table === '') ? $this->table : $table;
216✔
627

628
        // Ensure we have a good db connection
629
        if (! $this->db instanceof BaseConnection) {
216✔
UNCOV
630
            $this->db = Database::connect($this->DBGroup);
×
631
        }
632

633
        $builder = $this->db->table($table);
216✔
634

635
        // Only consider it "shared" if the table is correct
636
        if ($table === $this->table) {
216✔
637
            $this->builder = $builder;
216✔
638
        }
639

640
        return $builder;
216✔
641
    }
642

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

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

662
        foreach (array_keys($data) as $k) {
10✔
663
            $this->tempData['escape'][$k] = $escape;
10✔
664
        }
665

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

668
        return $this;
10✔
669
    }
670

671
    protected function shouldUpdate($row): bool
672
    {
673
        if (parent::shouldUpdate($row) === false) {
27✔
674
            return false;
12✔
675
        }
676

677
        if ($this->useAutoIncrement === true) {
17✔
678
            return true;
14✔
679
        }
680

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

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

697
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
698
        $this->tempData = [];
127✔
699

700
        return parent::insert($row, $returnID);
127✔
701
    }
702

703
    protected function doProtectFieldsForInsert(array $row): array
704
    {
705
        if (! $this->protectFields) {
128✔
706
            return $row;
9✔
707
        }
708

709
        if ($this->allowedFields === []) {
119✔
710
            throw DataException::forInvalidAllowedFields(static::class);
1✔
711
        }
712

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

719
            if (! in_array($key, $this->allowedFields, true)) {
117✔
720
                unset($row[$key]);
25✔
721
            }
722
        }
723

724
        return $row;
118✔
725
    }
726

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

743
        if ($attributes === []) {
13✔
744
            throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
1✔
745
        }
746

747
        $row = $this->where($attributes)->first();
12✔
748
        if ($row !== null) {
12✔
749
            return $row;
4✔
750
        }
751

752
        if (is_object($values)) {
8✔
753
            $values = $this->transformDataToArray($values, 'insert');
1✔
754
        }
755

756
        $data = array_merge($attributes, $values);
8✔
757

758
        try {
759
            $id = $this->insert($data);
8✔
760
        } catch (UniqueConstraintViolationException) {
1✔
761
            return $this->where($attributes)->first() ?? false;
1✔
762
        }
763

764
        if ($id === false) {
7✔
765
            if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
3✔
766
                return $this->where($attributes)->first() ?? false;
1✔
767
            }
768

769
            return false;
2✔
770
        }
771

772
        return $this->where($this->primaryKey, $id)->first() ?? false;
4✔
773
    }
774

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

786
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
787
        $this->tempData = [];
58✔
788

789
        return parent::update($id, $row);
58✔
790
    }
791

792
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
793
    {
794
        return parent::objectToRawArray($object, $onlyChanged);
25✔
795
    }
796

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

808
        return $this->builder()->{$name} ?? null;
1✔
809
    }
810

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

820
        return isset($this->builder()->{$name});
1✔
821
    }
822

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

834
        if (method_exists($this->db, $name)) {
60✔
835
            $result = $this->db->{$name}(...$params);
2✔
836
        } elseif (method_exists($builder, $name)) {
59✔
837
            $this->checkBuilderMethod($name);
58✔
838

839
            $result = $builder->{$name}(...$params);
56✔
840
        } else {
841
            throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
1✔
842
        }
843

844
        if ($result instanceof BaseBuilder) {
57✔
845
            return $this;
56✔
846
        }
847

848
        return $result;
2✔
849
    }
850

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