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

codeigniter4 / CodeIgniter4 / 26717872894

31 May 2026 04:19PM UTC coverage: 88.556% (+0.08%) from 88.475%
26717872894

Pull #10242

github

web-flow
Merge 47dc3835f into 2ef1571f5
Pull Request #10242: feat: add immutable URI query variable helpers

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

155 existing lines in 9 files now uncovered.

24267 of 27403 relevant lines covered (88.56%)

223.65 hits per line

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

98.58
/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\BaseResult;
20
use CodeIgniter\Database\ConnectionInterface;
21
use CodeIgniter\Database\Exceptions\DatabaseException;
22
use CodeIgniter\Database\Exceptions\DataException;
23
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
24
use CodeIgniter\Database\Query;
25
use CodeIgniter\Entity\Entity;
26
use CodeIgniter\Exceptions\BadMethodCallException;
27
use CodeIgniter\Exceptions\InvalidArgumentException;
28
use CodeIgniter\Exceptions\ModelException;
29
use CodeIgniter\Validation\ValidationInterface;
30
use Config\Database;
31
use Config\Feature;
32
use Generator;
33
use stdClass;
34

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

118
    /**
119
     * The table's primary key.
120
     *
121
     * @var string
122
     */
123
    protected $primaryKey = 'id';
124

125
    /**
126
     * Whether primary key uses auto increment.
127
     *
128
     * @var bool
129
     */
130
    protected $useAutoIncrement = true;
131

132
    /**
133
     * Query Builder object.
134
     *
135
     * @var BaseBuilder|null
136
     */
137
    protected $builder;
138

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

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

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

167
    public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
168
    {
169
        /** @var BaseConnection $db */
170
        $db ??= Database::connect($this->DBGroup);
420✔
171

172
        $this->db = $db;
420✔
173

174
        parent::__construct($validation);
420✔
175
    }
176

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

186
        return $this;
1✔
187
    }
188

189
    protected function doFind(bool $singleton, $id = null)
190
    {
191
        $builder = $this->builder();
56✔
192
        $useCast = $this->useCasts();
55✔
193

194
        if ($useCast) {
55✔
195
            $returnType = $this->tempReturnType;
18✔
196
            $this->asArray();
18✔
197
        }
198

199
        if ($this->tempUseSoftDeletes) {
55✔
200
            $builder->where($this->table . '.' . $this->deletedField, null);
31✔
201
        }
202

203
        $row  = null;
55✔
204
        $rows = [];
55✔
205

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

218
        if ($useCast) {
55✔
219
            $this->tempReturnType = $returnType;
18✔
220

221
            if ($singleton) {
18✔
222
                if ($row === null) {
15✔
223
                    return null;
1✔
224
                }
225

226
                return $this->convertToReturnType($row, $returnType);
14✔
227
            }
228

229
            foreach ($rows as $i => $row) {
3✔
230
                $rows[$i] = $this->convertToReturnType($row, $returnType);
3✔
231
            }
232

233
            return $rows;
3✔
234
        }
235

236
        if ($singleton) {
37✔
237
            return $row;
29✔
238
        }
239

240
        return $rows;
9✔
241
    }
242

243
    protected function doFindColumn(string $columnName)
244
    {
245
        return $this->select($columnName)->asArray()->find();
2✔
246
    }
247

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

260
        $builder = $this->builder();
22✔
261

262
        $useCast = $this->useCasts();
22✔
263
        if ($useCast) {
22✔
264
            $returnType = $this->tempReturnType;
5✔
265
            $this->asArray();
5✔
266
        }
267

268
        if ($this->tempUseSoftDeletes) {
22✔
269
            $builder->where($this->table . '.' . $this->deletedField, null);
10✔
270
        }
271

272
        $results = $builder->limit($limit, $offset)
22✔
273
            ->get()
22✔
274
            ->getResult($this->tempReturnType);
22✔
275

276
        if ($useCast) {
22✔
277
            foreach ($results as $i => $row) {
5✔
278
                $results[$i] = $this->convertToReturnType($row, $returnType);
4✔
279
            }
280

281
            $this->tempReturnType = $returnType;
5✔
282
        }
283

284
        return $results;
22✔
285
    }
286

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

297
        $useCast = $this->useCasts();
36✔
298
        if ($useCast) {
36✔
299
            $returnType = $this->tempReturnType;
5✔
300
            $this->asArray();
5✔
301
        }
302

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

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

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

317
        if ($useCast && $row !== null) {
36✔
318
            $row = $this->convertToReturnType($row, $returnType);
4✔
319

320
            $this->tempReturnType = $returnType;
4✔
321
        }
322

323
        return $row;
36✔
324
    }
325

326
    protected function doInsert(array $row)
327
    {
328
        $escape       = $this->escape;
102✔
329
        $this->escape = [];
102✔
330

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

338
            // Validate the primary key value (arrays not allowed for insert)
339
            $this->validateID($row[$this->primaryKey], false);
13✔
340
        }
341

342
        $builder = $this->builder();
91✔
343

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

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

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

373
            $result = $this->db->query($sql);
1✔
374
        } else {
375
            $result = $builder->insert();
90✔
376
        }
377

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

383
        return $result;
91✔
384
    }
385

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

396
                // Validate the primary key value
397
                $this->validateID($row[$this->primaryKey], false);
11✔
398
            }
399
        }
400

401
        return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize);
11✔
402
    }
403

404
    protected function doUpdate($id = null, $row = null): bool
405
    {
406
        $escape       = $this->escape;
38✔
407
        $this->escape = [];
38✔
408

409
        $builder = $this->builder();
38✔
410

411
        if (is_array($id) && $id !== []) {
38✔
412
            $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
32✔
413
        }
414

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

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

426
        return $builder->update();
37✔
427
    }
428

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

434
    protected function doDelete($id = null, bool $purge = false)
435
    {
436
        $set     = [];
38✔
437
        $builder = $this->builder();
38✔
438

439
        if (is_array($id) && $id !== []) {
38✔
440
            $builder = $builder->whereIn($this->primaryKey, $id);
24✔
441
        }
442

443
        if ($this->useSoftDeletes && ! $purge) {
38✔
444
            if ($builder->getCompiledQBWhere() === []) {
27✔
445
                throw new DatabaseException(
3✔
446
                    'Deletes are not allowed unless they contain a "where" or "like" clause.',
3✔
447
                );
3✔
448
            }
449

450
            $builder->where($this->deletedField);
24✔
451

452
            $set[$this->deletedField] = $this->setDate();
24✔
453

454
            if ($this->useTimestamps && $this->updatedField !== '') {
23✔
455
                $set[$this->updatedField] = $this->setDate();
1✔
456
            }
457

458
            return $builder->update($set);
23✔
459
        }
460

461
        return $builder->delete();
11✔
462
    }
463

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

471
    protected function doOnlyDeleted()
472
    {
473
        $this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL');
1✔
474
    }
475

476
    protected function doReplace(?array $row = null, bool $returnSQL = false)
477
    {
478
        return $this->builder()->testMode($returnSQL)->replace($row);
2✔
479
    }
480

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

493
        if ((int) $error['code'] === 0) {
2✔
494
            return [];
2✔
495
        }
496

UNCOV
497
        return [$this->db::class => $error['message']];
×
498
    }
499

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

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

510
                $primaryKey = $row->{$this->primaryKey};
9✔
511

512
                // Restore Entity casting setting.
513
                $row->cast($cast);
9✔
514

515
                return $primaryKey;
9✔
516
            }
517

518
            if (! $row instanceof Entity && isset($row->{$this->primaryKey})) {
9✔
519
                return $row->{$this->primaryKey};
5✔
520
            }
521
        }
522

523
        if (is_array($row) && isset($row[$this->primaryKey])) {
15✔
524
            return $row[$this->primaryKey];
4✔
525
        }
526

527
        return null;
11✔
528
    }
529

530
    public function countAllResults(bool $reset = true, bool $test = false)
531
    {
532
        $this->prepareSoftDeleteQuery($reset);
17✔
533

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

537
    /**
538
     * Explains the current Model query.
539
     *
540
     * @return BaseResult|false|Query|string Returns a SQL string if in test mode.
541
     */
542
    public function explain(bool $reset = true, bool $test = false)
543
    {
544
        $this->prepareSoftDeleteQuery($reset);
2✔
545

546
        return $this->builder()->testMode($test)->explain($reset);
2✔
547
    }
548

549
    /**
550
     * Determines whether the current Model query would return at least one row.
551
     *
552
     * @return bool|string Returns a SQL string if in test mode.
553
     */
554
    public function exists(bool $reset = true, bool $test = false)
555
    {
556
        $this->prepareSoftDeleteQuery($reset);
1✔
557

558
        return $this->builder()->testMode($test)->exists($reset);
1✔
559
    }
560

561
    /**
562
     * Determines whether the current Model query would not return any rows.
563
     *
564
     * @return bool|string Returns a SQL string if in test mode.
565
     */
566
    public function doesntExist(bool $reset = true, bool $test = false)
567
    {
568
        $this->prepareSoftDeleteQuery($reset);
1✔
569

570
        return $this->builder()->testMode($test)->doesntExist($reset);
1✔
571
    }
572

573
    /**
574
     * Applies the Model soft-delete constraint before terminal Builder operations.
575
     */
576
    private function prepareSoftDeleteQuery(bool $reset): void
577
    {
578
        if ($this->tempUseSoftDeletes) {
21✔
579
            $this->builder()->where($this->table . '.' . $this->deletedField, null);
9✔
580
        }
581

582
        // When $reset === false, the $tempUseSoftDeletes will be
583
        // dependent on $useSoftDeletes value because we don't
584
        // want to add the same "where" condition for the second time.
585
        $this->tempUseSoftDeletes = $reset
21✔
586
            ? $this->useSoftDeletes
14✔
587
            : ($this->useSoftDeletes ? false : $this->useSoftDeletes);
12✔
588
    }
589

590
    /**
591
     * Iterates over the result set in chunks of the specified size.
592
     *
593
     * @param int $size The number of records to retrieve in each chunk.
594
     *
595
     * @return Generator<list<array<string, string>>|list<object>>
596
     */
597
    private function iterateChunks(int $size): Generator
598
    {
599
        if ($size <= 0) {
12✔
600
            throw new InvalidArgumentException('$size must be a positive integer.');
4✔
601
        }
602

603
        $total  = $this->builder()->countAllResults(false);
8✔
604
        $offset = 0;
8✔
605

606
        while ($offset < $total) {
8✔
607
            $builder = clone $this->builder();
6✔
608
            $rows    = $builder->get($size, $offset);
6✔
609

610
            if (! $rows) {
6✔
UNCOV
611
                throw DataException::forEmptyDataset('chunk');
×
612
            }
613

614
            $rows = $rows->getResult($this->tempReturnType);
6✔
615

616
            $offset += $size;
6✔
617

618
            if ($rows === []) {
6✔
UNCOV
619
                continue;
×
620
            }
621

622
            yield $rows;
6✔
623
        }
624
    }
625

626
    /**
627
     * {@inheritDoc}
628
     */
629
    public function chunk(int $size, Closure $userFunc)
630
    {
631
        foreach ($this->iterateChunks($size) as $rows) {
6✔
632
            foreach ($rows as $row) {
3✔
633
                if ($userFunc($row) === false) {
3✔
634
                    return;
1✔
635
                }
636
            }
637
        }
638
    }
639

640
    /**
641
     * {@inheritDoc}
642
     */
643
    public function chunkRows(int $size, Closure $userFunc): void
644
    {
645
        foreach ($this->iterateChunks($size) as $rows) {
6✔
646
            if ($userFunc($rows) === false) {
3✔
647
                return;
1✔
648
            }
649
        }
650
    }
651

652
    /**
653
     * Provides a shared instance of the Query Builder.
654
     *
655
     * @param non-empty-string|null $table
656
     *
657
     * @return BaseBuilder
658
     *
659
     * @throws ModelException
660
     */
661
    public function builder(?string $table = null)
662
    {
663
        // Check for an existing Builder
664
        if ($this->builder instanceof BaseBuilder) {
221✔
665
            // Make sure the requested table matches the builder
666
            if ((string) $table !== '' && $this->builder->getTable() !== $table) {
137✔
667
                return $this->db->table($table);
1✔
668
            }
669

670
            return $this->builder;
137✔
671
        }
672

673
        // We're going to force a primary key to exist
674
        // so we don't have overly convoluted code,
675
        // and future features are likely to require them.
676
        if ($this->primaryKey === '') {
221✔
677
            throw ModelException::forNoPrimaryKey(static::class);
1✔
678
        }
679

680
        $table = ((string) $table === '') ? $this->table : $table;
220✔
681

682
        // Ensure we have a good db connection
683
        if (! $this->db instanceof BaseConnection) {
220✔
UNCOV
684
            $this->db = Database::connect($this->DBGroup);
×
685
        }
686

687
        $builder = $this->db->table($table);
220✔
688

689
        // Only consider it "shared" if the table is correct
690
        if ($table === $this->table) {
220✔
691
            $this->builder = $builder;
220✔
692
        }
693

694
        return $builder;
220✔
695
    }
696

697
    /**
698
     * Captures the builder's set() method so that we can validate the
699
     * data here. This allows it to be used with any of the other
700
     * builder methods and still get validated data, like replace.
701
     *
702
     * @param object|row_array|string           $key    Field name, or an array of field/value pairs, or an object
703
     * @param bool|float|int|object|string|null $value  Field value, if $key is a single field
704
     * @param bool|null                         $escape Whether to escape values
705
     *
706
     * @return $this
707
     */
708
    public function set($key, $value = '', ?bool $escape = null)
709
    {
710
        if (is_object($key)) {
10✔
711
            $key = $key instanceof stdClass ? (array) $key : $this->objectToArray($key);
2✔
712
        }
713

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

716
        foreach (array_keys($data) as $k) {
10✔
717
            $this->tempData['escape'][$k] = $escape;
10✔
718
        }
719

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

722
        return $this;
10✔
723
    }
724

725
    protected function shouldUpdate($row): bool
726
    {
727
        if (parent::shouldUpdate($row) === false) {
27✔
728
            return false;
12✔
729
        }
730

731
        if ($this->useAutoIncrement === true) {
17✔
732
            return true;
14✔
733
        }
734

735
        // When useAutoIncrement feature is disabled, check
736
        // in the database if given record already exists
737
        return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1;
3✔
738
    }
739

740
    public function insert($row = null, bool $returnID = true)
741
    {
742
        if (isset($this->tempData['data'])) {
127✔
743
            if ($row === null) {
2✔
744
                $row = $this->tempData['data'];
1✔
745
            } else {
746
                $row = $this->transformDataToArray($row, 'insert');
1✔
747
                $row = array_merge($this->tempData['data'], $row);
1✔
748
            }
749
        }
750

751
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
752
        $this->tempData = [];
127✔
753

754
        return parent::insert($row, $returnID);
127✔
755
    }
756

757
    protected function doProtectFieldsForInsert(array $row): array
758
    {
759
        if (! $this->protectFields) {
128✔
760
            return $row;
9✔
761
        }
762

763
        if ($this->allowedFields === []) {
119✔
764
            throw DataException::forInvalidAllowedFields(static::class);
1✔
765
        }
766

767
        foreach (array_keys($row) as $key) {
118✔
768
            // Do not remove the non-auto-incrementing primary key data.
769
            if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
117✔
770
                continue;
25✔
771
            }
772

773
            if (! in_array($key, $this->allowedFields, true)) {
117✔
774
                unset($row[$key]);
25✔
775
            }
776
        }
777

778
        return $row;
118✔
779
    }
780

781
    /**
782
     * Finds the first row matching attributes or inserts a new row.
783
     *
784
     * Note: without a DB unique constraint, this is not race-safe.
785
     *
786
     * @param array<string, mixed>|object $attributes
787
     * @param array<string, mixed>|object $values
788
     *
789
     * @return array<string, mixed>|false|object
790
     */
791
    public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object
792
    {
793
        if (is_object($attributes)) {
13✔
794
            $attributes = $this->transformDataToArray($attributes, 'insert');
2✔
795
        }
796

797
        if ($attributes === []) {
13✔
798
            throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
1✔
799
        }
800

801
        $row = $this->where($attributes)->first();
12✔
802
        if ($row !== null) {
12✔
803
            return $row;
4✔
804
        }
805

806
        if (is_object($values)) {
8✔
807
            $values = $this->transformDataToArray($values, 'insert');
1✔
808
        }
809

810
        $data = array_merge($attributes, $values);
8✔
811

812
        try {
813
            $id = $this->insert($data);
8✔
814
        } catch (UniqueConstraintViolationException) {
1✔
815
            return $this->where($attributes)->first() ?? false;
1✔
816
        }
817

818
        if ($id === false) {
7✔
819
            if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
3✔
820
                return $this->where($attributes)->first() ?? false;
1✔
821
            }
822

823
            return false;
2✔
824
        }
825

826
        return $this->where($this->primaryKey, $id)->first() ?? false;
4✔
827
    }
828

829
    public function update($id = null, $row = null): bool
830
    {
831
        if (isset($this->tempData['data'])) {
58✔
832
            if ($row === null) {
6✔
833
                $row = $this->tempData['data'];
5✔
834
            } else {
835
                $row = $this->transformDataToArray($row, 'update');
1✔
836
                $row = array_merge($this->tempData['data'], $row);
1✔
837
            }
838
        }
839

840
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
841
        $this->tempData = [];
58✔
842

843
        return parent::update($id, $row);
58✔
844
    }
845

846
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
847
    {
848
        return parent::objectToRawArray($object, $onlyChanged);
25✔
849
    }
850

851
    /**
852
     * Provides/instantiates the builder/db connection and model's table/primary key names and return type.
853
     *
854
     * @return array<int|string, mixed>|BaseBuilder|bool|float|int|object|string|null
855
     */
856
    public function __get(string $name)
857
    {
858
        if (parent::__isset($name)) {
50✔
859
            return parent::__get($name);
50✔
860
        }
861

862
        return $this->builder()->{$name} ?? null;
1✔
863
    }
864

865
    /**
866
     * Checks for the existence of properties across this model, builder, and db connection.
867
     */
868
    public function __isset(string $name): bool
869
    {
870
        if (parent::__isset($name)) {
47✔
871
            return true;
47✔
872
        }
873

874
        return isset($this->builder()->{$name});
1✔
875
    }
876

877
    /**
878
     * Provides direct access to method in the builder (if available)
879
     * and the database connection.
880
     *
881
     * @return $this|array<int|string, mixed>|BaseBuilder|bool|float|int|object|string|null
882
     */
883
    public function __call(string $name, array $params)
884
    {
885
        $builder = $this->builder();
64✔
886
        $result  = null;
64✔
887

888
        if (method_exists($this->db, $name)) {
64✔
889
            $result = $this->db->{$name}(...$params);
2✔
890
        } elseif (method_exists($builder, $name)) {
63✔
891
            $this->checkBuilderMethod($name);
62✔
892

893
            $result = $builder->{$name}(...$params);
60✔
894
        } else {
895
            throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
1✔
896
        }
897

898
        if ($result instanceof BaseBuilder) {
61✔
899
            return $this;
60✔
900
        }
901

902
        return $result;
2✔
903
    }
904

905
    /**
906
     * Checks the Builder method name that should not be used in the Model.
907
     */
908
    private function checkBuilderMethod(string $name): void
909
    {
910
        if (in_array($name, $this->builderMethodsNotAvailable, true)) {
62✔
911
            throw ModelException::forMethodNotAvailable(static::class, $name . '()');
2✔
912
        }
913
    }
914
}
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