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

codeigniter4 / CodeIgniter4 / 25683594391

11 May 2026 04:38PM UTC coverage: 88.432% (+0.005%) from 88.427%
25683594391

Pull #10185

github

web-flow
Merge 129fae76f into 8ff764e85
Pull Request #10185: feat: add Query Builder whereExists methods

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

4 existing lines in 1 file now uncovered.

23867 of 26989 relevant lines covered (88.43%)

219.46 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-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
92
 * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
93
 * @phpstan-import-type row_array from BaseModel
94
 */
95
class Model extends BaseModel
96
{
97
    /**
98
     * Name of database table.
99
     *
100
     * @var string
101
     */
102
    protected $table;
103

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

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

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

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

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

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

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

158
        $this->db = $db;
416✔
159

160
        parent::__construct($validation);
416✔
161
    }
162

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

172
        return $this;
1✔
173
    }
174

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

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

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

189
        $row  = null;
55✔
190
        $rows = [];
55✔
191

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

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

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

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

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

219
            return $rows;
3✔
220
        }
221

222
        if ($singleton) {
37✔
223
            return $row;
29✔
224
        }
225

226
        return $rows;
9✔
227
    }
228

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

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

246
        $builder = $this->builder();
22✔
247

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

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

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

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

267
            $this->tempReturnType = $returnType;
5✔
268
        }
269

270
        return $results;
22✔
271
    }
272

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

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

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

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

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

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

306
            $this->tempReturnType = $returnType;
4✔
307
        }
308

309
        return $row;
36✔
310
    }
311

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

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

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

328
        $builder = $this->builder();
91✔
329

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

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

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

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

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

369
        return $result;
91✔
370
    }
371

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

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

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

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

395
        $builder = $this->builder();
38✔
396

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

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

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

412
        return $builder->update();
37✔
413
    }
414

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

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

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

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

436
            $builder->where($this->deletedField);
22✔
437

438
            $set[$this->deletedField] = $this->setDate();
22✔
439

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

444
            return $builder->update($set);
21✔
445
        }
446

447
        return $builder->delete();
11✔
448
    }
449

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

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

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

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

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

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

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

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

496
                $primaryKey = $row->{$this->primaryKey};
9✔
497

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

501
                return $primaryKey;
9✔
502
            }
503

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

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

513
        return null;
11✔
514
    }
515

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

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

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

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

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

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

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

556
            $rows = $rows->getResult($this->tempReturnType);
6✔
557

558
            $offset += $size;
6✔
559

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

564
            yield $rows;
6✔
565
        }
566
    }
567

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

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

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

612
            return $this->builder;
133✔
613
        }
614

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

622
        $table = ((string) $table === '') ? $this->table : $table;
216✔
623

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

629
        $builder = $this->db->table($table);
216✔
630

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

636
        return $builder;
216✔
637
    }
638

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

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

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

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

664
        return $this;
10✔
665
    }
666

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

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

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

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

693
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
694
        $this->tempData = [];
127✔
695

696
        return parent::insert($row, $returnID);
127✔
697
    }
698

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

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

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

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

720
        return $row;
118✔
721
    }
722

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

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

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

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

752
        $data = array_merge($attributes, $values);
8✔
753

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

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

765
            return false;
2✔
766
        }
767

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

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

782
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
783
        $this->tempData = [];
58✔
784

785
        return parent::update($id, $row);
58✔
786
    }
787

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

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

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

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

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

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

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

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

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

844
        return $result;
2✔
845
    }
846

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