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

codeigniter4 / CodeIgniter4 / 26030147274

18 May 2026 11:17AM UTC coverage: 88.488% (+0.02%) from 88.464%
26030147274

Pull #10215

github

web-flow
Merge 331951773 into 37b8b37b5
Pull Request #10215: feat: add Query Builder exists and doesntExist methods

58 of 62 new or added lines in 2 files covered. (93.55%)

4 existing lines in 1 file now uncovered.

24189 of 27336 relevant lines covered (88.49%)

219.81 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 bool  doesntExist(bool $reset = true)
46
 * @method bool  exists(bool $reset = true)
47
 * @method $this groupBy($by, ?bool $escape = null)
48
 * @method $this groupEnd()
49
 * @method $this groupStart()
50
 * @method $this having($key, $value = null, ?bool $escape = null)
51
 * @method $this havingGroupEnd()
52
 * @method $this havingGroupStart()
53
 * @method $this havingIn(?string $key = null, $values = null, ?bool $escape = null)
54
 * @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
55
 * @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
56
 * @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
57
 * @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
58
 * @method $this limit(?int $value = null, ?int $offset = 0)
59
 * @method $this notGroupStart()
60
 * @method $this notHavingGroupStart()
61
 * @method $this notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
62
 * @method $this notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
63
 * @method $this offset(int $offset)
64
 * @method $this orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
65
 * @method $this orGroupStart()
66
 * @method $this orHaving($key, $value = null, ?bool $escape = null)
67
 * @method $this orHavingGroupStart()
68
 * @method $this orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
69
 * @method $this orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
70
 * @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
71
 * @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
72
 * @method $this orNotGroupStart()
73
 * @method $this orNotHavingGroupStart()
74
 * @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
75
 * @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
76
 * @method $this orWhere($key, $value = null, ?bool $escape = null)
77
 * @method $this orWhereBetween(?string $key = null, $values = null, ?bool $escape = null)
78
 * @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
79
 * @method $this orWhereExists($subquery)
80
 * @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
81
 * @method $this orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
82
 * @method $this orWhereNotExists($subquery)
83
 * @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
84
 * @method $this select($select = '*', ?bool $escape = null)
85
 * @method $this selectAvg(string $select = '', string $alias = '')
86
 * @method $this selectCount(string $select = '', string $alias = '')
87
 * @method $this selectMax(string $select = '', string $alias = '')
88
 * @method $this selectMin(string $select = '', string $alias = '')
89
 * @method $this selectSum(string $select = '', string $alias = '')
90
 * @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
91
 * @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
92
 * @method $this where($key, $value = null, ?bool $escape = null)
93
 * @method $this whereBetween(?string $key = null, $values = null, ?bool $escape = null)
94
 * @method $this whereColumn(string $first, string $second, ?bool $escape = null)
95
 * @method $this whereExists($subquery)
96
 * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
97
 * @method $this whereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
98
 * @method $this whereNotExists($subquery)
99
 * @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
100
 *
101
 * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
102
 * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)
103
 * @phpstan-import-type row_array from BaseModel
104
 */
105
class Model extends BaseModel
106
{
107
    /**
108
     * Name of database table.
109
     *
110
     * @var string
111
     */
112
    protected $table;
113

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

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

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

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

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

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

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

168
        $this->db = $db;
416✔
169

170
        parent::__construct($validation);
416✔
171
    }
172

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

182
        return $this;
1✔
183
    }
184

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

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

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

199
        $row  = null;
55✔
200
        $rows = [];
55✔
201

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

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

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

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

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

229
            return $rows;
3✔
230
        }
231

232
        if ($singleton) {
37✔
233
            return $row;
29✔
234
        }
235

236
        return $rows;
9✔
237
    }
238

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

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

256
        $builder = $this->builder();
22✔
257

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

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

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

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

277
            $this->tempReturnType = $returnType;
5✔
278
        }
279

280
        return $results;
22✔
281
    }
282

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

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

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

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

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

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

316
            $this->tempReturnType = $returnType;
4✔
317
        }
318

319
        return $row;
36✔
320
    }
321

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

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

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

338
        $builder = $this->builder();
91✔
339

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

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

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

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

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

379
        return $result;
91✔
380
    }
381

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

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

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

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

405
        $builder = $this->builder();
38✔
406

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

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

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

422
        return $builder->update();
37✔
423
    }
424

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

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

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

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

446
            $builder->where($this->deletedField);
22✔
447

448
            $set[$this->deletedField] = $this->setDate();
22✔
449

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

454
            return $builder->update($set);
21✔
455
        }
456

457
        return $builder->delete();
11✔
458
    }
459

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

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

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

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

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

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

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

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

506
                $primaryKey = $row->{$this->primaryKey};
9✔
507

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

511
                return $primaryKey;
9✔
512
            }
513

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

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

523
        return null;
11✔
524
    }
525

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

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

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

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

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

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

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

566
            $rows = $rows->getResult($this->tempReturnType);
6✔
567

568
            $offset += $size;
6✔
569

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

574
            yield $rows;
6✔
575
        }
576
    }
577

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

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

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

622
            return $this->builder;
133✔
623
        }
624

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

632
        $table = ((string) $table === '') ? $this->table : $table;
216✔
633

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

639
        $builder = $this->db->table($table);
216✔
640

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

646
        return $builder;
216✔
647
    }
648

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

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

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

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

674
        return $this;
10✔
675
    }
676

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

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

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

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

703
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
704
        $this->tempData = [];
127✔
705

706
        return parent::insert($row, $returnID);
127✔
707
    }
708

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

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

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

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

730
        return $row;
118✔
731
    }
732

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

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

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

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

762
        $data = array_merge($attributes, $values);
8✔
763

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

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

775
            return false;
2✔
776
        }
777

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

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

792
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
793
        $this->tempData = [];
58✔
794

795
        return parent::update($id, $row);
58✔
796
    }
797

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

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

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

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

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

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

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

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

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

854
        return $result;
2✔
855
    }
856

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