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

codeigniter4 / CodeIgniter4 / 25526514026

07 May 2026 10:54PM UTC coverage: 88.394% (+0.1%) from 88.287%
25526514026

Pull #10162

github

web-flow
Merge 919c234ee into 2c774cae8
Pull Request #10162: feat: classify retryable transaction exceptions

19 of 20 new or added lines in 6 files covered. (95.0%)

109 existing lines in 5 files now uncovered.

23763 of 26883 relevant lines covered (88.39%)

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

102
    /**
103
     * The table's primary key.
104
     *
105
     * @var string
106
     */
107
    protected $primaryKey = 'id';
108

109
    /**
110
     * Whether primary key uses auto increment.
111
     *
112
     * @var bool
113
     */
114
    protected $useAutoIncrement = true;
115

116
    /**
117
     * Query Builder object.
118
     *
119
     * @var BaseBuilder|null
120
     */
121
    protected $builder;
122

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

132
    /**
133
     * Escape array that maps usage of escape
134
     * flag for every parameter.
135
     *
136
     * @var array<int|string, bool|null>
137
     */
138
    protected $escape = [];
139

140
    /**
141
     * Builder method names that should not be used in the Model.
142
     *
143
     * @var list<string>
144
     */
145
    private array $builderMethodsNotAvailable = [
146
        'getCompiledInsert',
147
        'getCompiledSelect',
148
        'getCompiledUpdate',
149
    ];
150

151
    public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
152
    {
153
        /** @var BaseConnection $db */
154
        $db ??= Database::connect($this->DBGroup);
416✔
155

156
        $this->db = $db;
416✔
157

158
        parent::__construct($validation);
416✔
159
    }
160

161
    /**
162
     * Specify the table associated with a model.
163
     *
164
     * @return $this
165
     */
166
    public function setTable(string $table)
167
    {
168
        $this->table = $table;
1✔
169

170
        return $this;
1✔
171
    }
172

173
    protected function doFind(bool $singleton, $id = null)
174
    {
175
        $builder = $this->builder();
56✔
176
        $useCast = $this->useCasts();
55✔
177

178
        if ($useCast) {
55✔
179
            $returnType = $this->tempReturnType;
18✔
180
            $this->asArray();
18✔
181
        }
182

183
        if ($this->tempUseSoftDeletes) {
55✔
184
            $builder->where($this->table . '.' . $this->deletedField, null);
31✔
185
        }
186

187
        $row  = null;
55✔
188
        $rows = [];
55✔
189

190
        if (is_array($id)) {
55✔
191
            $rows = $builder->whereIn($this->table . '.' . $this->primaryKey, $id)
3✔
192
                ->get()
3✔
193
                ->getResult($this->tempReturnType);
3✔
194
        } elseif ($singleton) {
52✔
195
            $row = $builder->where($this->table . '.' . $this->primaryKey, $id)
44✔
196
                ->get()
44✔
197
                ->getFirstRow($this->tempReturnType);
44✔
198
        } else {
199
            $rows = $builder->get()->getResult($this->tempReturnType);
9✔
200
        }
201

202
        if ($useCast) {
55✔
203
            $this->tempReturnType = $returnType;
18✔
204

205
            if ($singleton) {
18✔
206
                if ($row === null) {
15✔
207
                    return null;
1✔
208
                }
209

210
                return $this->convertToReturnType($row, $returnType);
14✔
211
            }
212

213
            foreach ($rows as $i => $row) {
3✔
214
                $rows[$i] = $this->convertToReturnType($row, $returnType);
3✔
215
            }
216

217
            return $rows;
3✔
218
        }
219

220
        if ($singleton) {
37✔
221
            return $row;
29✔
222
        }
223

224
        return $rows;
9✔
225
    }
226

227
    protected function doFindColumn(string $columnName)
228
    {
229
        return $this->select($columnName)->asArray()->find();
2✔
230
    }
231

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

244
        $builder = $this->builder();
22✔
245

246
        $useCast = $this->useCasts();
22✔
247
        if ($useCast) {
22✔
248
            $returnType = $this->tempReturnType;
5✔
249
            $this->asArray();
5✔
250
        }
251

252
        if ($this->tempUseSoftDeletes) {
22✔
253
            $builder->where($this->table . '.' . $this->deletedField, null);
10✔
254
        }
255

256
        $results = $builder->limit($limit, $offset)
22✔
257
            ->get()
22✔
258
            ->getResult($this->tempReturnType);
22✔
259

260
        if ($useCast) {
22✔
261
            foreach ($results as $i => $row) {
5✔
262
                $results[$i] = $this->convertToReturnType($row, $returnType);
4✔
263
            }
264

265
            $this->tempReturnType = $returnType;
5✔
266
        }
267

268
        return $results;
22✔
269
    }
270

271
    /**
272
     * {@inheritDoc}
273
     *
274
     * Will take any previous Query Builder calls into account
275
     * when determining the result set.
276
     */
277
    protected function doFirst()
278
    {
279
        $builder = $this->builder();
36✔
280

281
        $useCast = $this->useCasts();
36✔
282
        if ($useCast) {
36✔
283
            $returnType = $this->tempReturnType;
5✔
284
            $this->asArray();
5✔
285
        }
286

287
        if ($this->tempUseSoftDeletes) {
36✔
288
            $builder->where($this->table . '.' . $this->deletedField, null);
31✔
289
        } elseif ($this->useSoftDeletes && ($builder->QBGroupBy === []) && $this->primaryKey !== '') {
13✔
290
            $builder->groupBy($this->table . '.' . $this->primaryKey);
6✔
291
        }
292

293
        // Some databases, like PostgreSQL, need order
294
        // information to consistently return correct results.
295
        if ($builder->QBGroupBy !== [] && ($builder->QBOrderBy === []) && $this->primaryKey !== '') {
36✔
296
            $builder->orderBy($this->table . '.' . $this->primaryKey, 'asc');
9✔
297
        }
298

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

301
        if ($useCast && $row !== null) {
36✔
302
            $row = $this->convertToReturnType($row, $returnType);
4✔
303

304
            $this->tempReturnType = $returnType;
4✔
305
        }
306

307
        return $row;
36✔
308
    }
309

310
    protected function doInsert(array $row)
311
    {
312
        $escape       = $this->escape;
102✔
313
        $this->escape = [];
102✔
314

315
        // Require non-empty primaryKey when
316
        // not using auto-increment feature
317
        if (! $this->useAutoIncrement) {
102✔
318
            if (! isset($row[$this->primaryKey])) {
15✔
319
                throw DataException::forEmptyPrimaryKey('insert');
2✔
320
            }
321

322
            // Validate the primary key value (arrays not allowed for insert)
323
            $this->validateID($row[$this->primaryKey], false);
13✔
324
        }
325

326
        $builder = $this->builder();
91✔
327

328
        // Must use the set() method to ensure to set the correct escape flag
329
        foreach ($row as $key => $val) {
91✔
330
            $builder->set($key, $val, $escape[$key] ?? null);
90✔
331
        }
332

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

347
                $sql = sprintf(
1✔
348
                    'INSERT INTO %s (%s) VALUES (%s)',
1✔
349
                    $table,
1✔
350
                    implode(',', $allFields),
1✔
351
                    substr(str_repeat(',DEFAULT', count($allFields)), 1),
1✔
352
                );
1✔
353
            } else {
354
                $sql = 'INSERT INTO ' . $table . ' DEFAULT VALUES';
1✔
355
            }
356

357
            $result = $this->db->query($sql);
1✔
358
        } else {
359
            $result = $builder->insert();
90✔
360
        }
361

362
        // If insertion succeeded then save the insert ID
363
        if ($result) {
91✔
364
            $this->insertID = $this->useAutoIncrement ? $this->db->insertID() : $row[$this->primaryKey];
88✔
365
        }
366

367
        return $result;
91✔
368
    }
369

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

380
                // Validate the primary key value
381
                $this->validateID($row[$this->primaryKey], false);
11✔
382
            }
383
        }
384

385
        return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize);
11✔
386
    }
387

388
    protected function doUpdate($id = null, $row = null): bool
389
    {
390
        $escape       = $this->escape;
38✔
391
        $this->escape = [];
38✔
392

393
        $builder = $this->builder();
38✔
394

395
        if (is_array($id) && $id !== []) {
38✔
396
            $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
32✔
397
        }
398

399
        // Must use the set() method to ensure to set the correct escape flag
400
        foreach ($row as $key => $val) {
38✔
401
            $builder->set($key, $val, $escape[$key] ?? null);
38✔
402
        }
403

404
        if ($builder->getCompiledQBWhere() === []) {
38✔
405
            throw new DatabaseException(
1✔
406
                'Updates are not allowed unless they contain a "where" or "like" clause.',
1✔
407
            );
1✔
408
        }
409

410
        return $builder->update();
37✔
411
    }
412

413
    protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
414
    {
415
        return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize);
5✔
416
    }
417

418
    protected function doDelete($id = null, bool $purge = false)
419
    {
420
        $set     = [];
36✔
421
        $builder = $this->builder();
36✔
422

423
        if (is_array($id) && $id !== []) {
36✔
424
            $builder = $builder->whereIn($this->primaryKey, $id);
22✔
425
        }
426

427
        if ($this->useSoftDeletes && ! $purge) {
36✔
428
            if ($builder->getCompiledQBWhere() === []) {
25✔
429
                throw new DatabaseException(
3✔
430
                    'Deletes are not allowed unless they contain a "where" or "like" clause.',
3✔
431
                );
3✔
432
            }
433

434
            $builder->where($this->deletedField);
22✔
435

436
            $set[$this->deletedField] = $this->setDate();
22✔
437

438
            if ($this->useTimestamps && $this->updatedField !== '') {
21✔
439
                $set[$this->updatedField] = $this->setDate();
1✔
440
            }
441

442
            return $builder->update($set);
21✔
443
        }
444

445
        return $builder->delete();
11✔
446
    }
447

448
    protected function doPurgeDeleted()
449
    {
450
        return $this->builder()
1✔
451
            ->where($this->table . '.' . $this->deletedField . ' IS NOT NULL')
1✔
452
            ->delete();
1✔
453
    }
454

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

460
    protected function doReplace(?array $row = null, bool $returnSQL = false)
461
    {
462
        return $this->builder()->testMode($returnSQL)->replace($row);
2✔
463
    }
464

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

477
        if ((int) $error['code'] === 0) {
2✔
478
            return [];
2✔
479
        }
480

UNCOV
481
        return [$this->db::class => $error['message']];
×
482
    }
483

484
    public function getIdValue($row)
485
    {
486
        if (is_object($row)) {
27✔
487
            // Get the raw or mapped primary key value of the Entity.
488
            if ($row instanceof Entity && $row->{$this->primaryKey} !== null) {
17✔
489
                $cast = $row->cast();
9✔
490

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

494
                $primaryKey = $row->{$this->primaryKey};
9✔
495

496
                // Restore Entity casting setting.
497
                $row->cast($cast);
9✔
498

499
                return $primaryKey;
9✔
500
            }
501

502
            if (! $row instanceof Entity && isset($row->{$this->primaryKey})) {
9✔
503
                return $row->{$this->primaryKey};
5✔
504
            }
505
        }
506

507
        if (is_array($row) && isset($row[$this->primaryKey])) {
15✔
508
            return $row[$this->primaryKey];
4✔
509
        }
510

511
        return null;
11✔
512
    }
513

514
    public function countAllResults(bool $reset = true, bool $test = false)
515
    {
516
        if ($this->tempUseSoftDeletes) {
17✔
517
            $this->builder()->where($this->table . '.' . $this->deletedField, null);
6✔
518
        }
519

520
        // When $reset === false, the $tempUseSoftDeletes will be
521
        // dependent on $useSoftDeletes value because we don't
522
        // want to add the same "where" condition for the second time.
523
        $this->tempUseSoftDeletes = $reset
17✔
524
            ? $this->useSoftDeletes
10✔
525
            : ($this->useSoftDeletes ? false : $this->useSoftDeletes);
12✔
526

527
        return $this->builder()->testMode($test)->countAllResults($reset);
17✔
528
    }
529

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

543
        $total  = $this->builder()->countAllResults(false);
8✔
544
        $offset = 0;
8✔
545

546
        while ($offset < $total) {
8✔
547
            $builder = clone $this->builder();
6✔
548
            $rows    = $builder->get($size, $offset);
6✔
549

550
            if (! $rows) {
6✔
UNCOV
551
                throw DataException::forEmptyDataset('chunk');
×
552
            }
553

554
            $rows = $rows->getResult($this->tempReturnType);
6✔
555

556
            $offset += $size;
6✔
557

558
            if ($rows === []) {
6✔
UNCOV
559
                continue;
×
560
            }
561

562
            yield $rows;
6✔
563
        }
564
    }
565

566
    /**
567
     * {@inheritDoc}
568
     */
569
    public function chunk(int $size, Closure $userFunc)
570
    {
571
        foreach ($this->iterateChunks($size) as $rows) {
6✔
572
            foreach ($rows as $row) {
3✔
573
                if ($userFunc($row) === false) {
3✔
574
                    return;
1✔
575
                }
576
            }
577
        }
578
    }
579

580
    /**
581
     * {@inheritDoc}
582
     */
583
    public function chunkRows(int $size, Closure $userFunc): void
584
    {
585
        foreach ($this->iterateChunks($size) as $rows) {
6✔
586
            if ($userFunc($rows) === false) {
3✔
587
                return;
1✔
588
            }
589
        }
590
    }
591

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

610
            return $this->builder;
133✔
611
        }
612

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

620
        $table = ((string) $table === '') ? $this->table : $table;
216✔
621

622
        // Ensure we have a good db connection
623
        if (! $this->db instanceof BaseConnection) {
216✔
UNCOV
624
            $this->db = Database::connect($this->DBGroup);
×
625
        }
626

627
        $builder = $this->db->table($table);
216✔
628

629
        // Only consider it "shared" if the table is correct
630
        if ($table === $this->table) {
216✔
631
            $this->builder = $builder;
216✔
632
        }
633

634
        return $builder;
216✔
635
    }
636

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

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

656
        foreach (array_keys($data) as $k) {
10✔
657
            $this->tempData['escape'][$k] = $escape;
10✔
658
        }
659

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

662
        return $this;
10✔
663
    }
664

665
    protected function shouldUpdate($row): bool
666
    {
667
        if (parent::shouldUpdate($row) === false) {
27✔
668
            return false;
12✔
669
        }
670

671
        if ($this->useAutoIncrement === true) {
17✔
672
            return true;
14✔
673
        }
674

675
        // When useAutoIncrement feature is disabled, check
676
        // in the database if given record already exists
677
        return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1;
3✔
678
    }
679

680
    public function insert($row = null, bool $returnID = true)
681
    {
682
        if (isset($this->tempData['data'])) {
127✔
683
            if ($row === null) {
2✔
684
                $row = $this->tempData['data'];
1✔
685
            } else {
686
                $row = $this->transformDataToArray($row, 'insert');
1✔
687
                $row = array_merge($this->tempData['data'], $row);
1✔
688
            }
689
        }
690

691
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
692
        $this->tempData = [];
127✔
693

694
        return parent::insert($row, $returnID);
127✔
695
    }
696

697
    protected function doProtectFieldsForInsert(array $row): array
698
    {
699
        if (! $this->protectFields) {
128✔
700
            return $row;
9✔
701
        }
702

703
        if ($this->allowedFields === []) {
119✔
704
            throw DataException::forInvalidAllowedFields(static::class);
1✔
705
        }
706

707
        foreach (array_keys($row) as $key) {
118✔
708
            // Do not remove the non-auto-incrementing primary key data.
709
            if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
117✔
710
                continue;
25✔
711
            }
712

713
            if (! in_array($key, $this->allowedFields, true)) {
117✔
714
                unset($row[$key]);
25✔
715
            }
716
        }
717

718
        return $row;
118✔
719
    }
720

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

737
        if ($attributes === []) {
13✔
738
            throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
1✔
739
        }
740

741
        $row = $this->where($attributes)->first();
12✔
742
        if ($row !== null) {
12✔
743
            return $row;
4✔
744
        }
745

746
        if (is_object($values)) {
8✔
747
            $values = $this->transformDataToArray($values, 'insert');
1✔
748
        }
749

750
        $data = array_merge($attributes, $values);
8✔
751

752
        try {
753
            $id = $this->insert($data);
8✔
754
        } catch (UniqueConstraintViolationException) {
1✔
755
            return $this->where($attributes)->first() ?? false;
1✔
756
        }
757

758
        if ($id === false) {
7✔
759
            if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
3✔
760
                return $this->where($attributes)->first() ?? false;
1✔
761
            }
762

763
            return false;
2✔
764
        }
765

766
        return $this->where($this->primaryKey, $id)->first() ?? false;
4✔
767
    }
768

769
    public function update($id = null, $row = null): bool
770
    {
771
        if (isset($this->tempData['data'])) {
58✔
772
            if ($row === null) {
6✔
773
                $row = $this->tempData['data'];
5✔
774
            } else {
775
                $row = $this->transformDataToArray($row, 'update');
1✔
776
                $row = array_merge($this->tempData['data'], $row);
1✔
777
            }
778
        }
779

780
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
781
        $this->tempData = [];
58✔
782

783
        return parent::update($id, $row);
58✔
784
    }
785

786
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
787
    {
788
        return parent::objectToRawArray($object, $onlyChanged);
25✔
789
    }
790

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

802
        return $this->builder()->{$name} ?? null;
1✔
803
    }
804

805
    /**
806
     * Checks for the existence of properties across this model, builder, and db connection.
807
     */
808
    public function __isset(string $name): bool
809
    {
810
        if (parent::__isset($name)) {
47✔
811
            return true;
47✔
812
        }
813

814
        return isset($this->builder()->{$name});
1✔
815
    }
816

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

828
        if (method_exists($this->db, $name)) {
60✔
829
            $result = $this->db->{$name}(...$params);
2✔
830
        } elseif (method_exists($builder, $name)) {
59✔
831
            $this->checkBuilderMethod($name);
58✔
832

833
            $result = $builder->{$name}(...$params);
56✔
834
        } else {
835
            throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
1✔
836
        }
837

838
        if ($result instanceof BaseBuilder) {
57✔
839
            return $this;
56✔
840
        }
841

842
        return $result;
2✔
843
    }
844

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