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

codeigniter4 / CodeIgniter4 / 28856305448

07 Jul 2026 09:33AM UTC coverage: 89.605% (+0.01%) from 89.592%
28856305448

Pull #10363

github

web-flow
Merge 5a43c4a62 into ff60c9a8e
Pull Request #10363: feat: Add ID-based model chunking

44 of 45 new or added lines in 2 files covered. (97.78%)

25307 of 28243 relevant lines covered (89.6%)

231.65 hits per line

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

98.47
/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\Database\RawSql;
26
use CodeIgniter\Entity\Entity;
27
use CodeIgniter\Exceptions\BadMethodCallException;
28
use CodeIgniter\Exceptions\InvalidArgumentException;
29
use CodeIgniter\Exceptions\ModelException;
30
use CodeIgniter\Validation\ValidationInterface;
31
use Config\Database;
32
use Config\Feature;
33
use Generator;
34
use stdClass;
35

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

121
    /**
122
     * The table's primary key.
123
     *
124
     * @var string
125
     */
126
    protected $primaryKey = 'id';
127

128
    /**
129
     * Whether primary key uses auto increment.
130
     *
131
     * @var bool
132
     */
133
    protected $useAutoIncrement = true;
134

135
    /**
136
     * Query Builder object.
137
     *
138
     * @var BaseBuilder|null
139
     */
140
    protected $builder;
141

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

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

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

171
    public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
172
    {
173
        /** @var BaseConnection $db */
174
        $db ??= Database::connect($this->DBGroup);
468✔
175

176
        $this->db = $db;
468✔
177

178
        parent::__construct($validation);
468✔
179
    }
180

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

190
        return $this;
1✔
191
    }
192

193
    protected function doFind(bool $singleton, $id = null)
194
    {
195
        $builder = $this->builder();
58✔
196
        $useCast = $this->useCasts();
57✔
197

198
        if ($useCast) {
57✔
199
            $returnType = $this->tempReturnType;
18✔
200
            $this->asArray();
18✔
201
        }
202

203
        if ($this->tempUseSoftDeletes) {
57✔
204
            $builder->where($this->table . '.' . $this->deletedField, null);
33✔
205
        }
206

207
        $row  = null;
57✔
208
        $rows = [];
57✔
209

210
        if (is_array($id)) {
57✔
211
            $rows = $builder->whereIn($this->table . '.' . $this->primaryKey, $id)
3✔
212
                ->get()
3✔
213
                ->getResult($this->tempReturnType);
3✔
214
        } elseif ($singleton) {
54✔
215
            $row = $builder->where($this->table . '.' . $this->primaryKey, $id)
46✔
216
                ->get()
46✔
217
                ->getFirstRow($this->tempReturnType);
46✔
218
        } else {
219
            $rows = $builder->get()->getResult($this->tempReturnType);
9✔
220
        }
221

222
        if ($useCast) {
57✔
223
            $this->tempReturnType = $returnType;
18✔
224

225
            if ($singleton) {
18✔
226
                if ($row === null) {
15✔
227
                    return null;
1✔
228
                }
229

230
                return $this->convertToReturnType($row, $returnType);
14✔
231
            }
232

233
            foreach ($rows as $i => $row) {
3✔
234
                $rows[$i] = $this->convertToReturnType($row, $returnType);
3✔
235
            }
236

237
            return $rows;
3✔
238
        }
239

240
        if ($singleton) {
39✔
241
            return $row;
31✔
242
        }
243

244
        return $rows;
9✔
245
    }
246

247
    protected function doFindColumn(string $columnName)
248
    {
249
        return $this->select($columnName)->asArray()->find();
2✔
250
    }
251

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

264
        $builder = $this->builder();
22✔
265

266
        $useCast = $this->useCasts();
22✔
267
        if ($useCast) {
22✔
268
            $returnType = $this->tempReturnType;
5✔
269
            $this->asArray();
5✔
270
        }
271

272
        if ($this->tempUseSoftDeletes) {
22✔
273
            $builder->where($this->table . '.' . $this->deletedField, null);
10✔
274
        }
275

276
        $results = $builder->limit($limit, $offset)
22✔
277
            ->get()
22✔
278
            ->getResult($this->tempReturnType);
22✔
279

280
        if ($useCast) {
22✔
281
            foreach ($results as $i => $row) {
5✔
282
                $results[$i] = $this->convertToReturnType($row, $returnType);
4✔
283
            }
284

285
            $this->tempReturnType = $returnType;
5✔
286
        }
287

288
        return $results;
22✔
289
    }
290

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

301
        $useCast = $this->useCasts();
36✔
302
        if ($useCast) {
36✔
303
            $returnType = $this->tempReturnType;
5✔
304
            $this->asArray();
5✔
305
        }
306

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

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

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

321
        if ($useCast && $row !== null) {
36✔
322
            $row = $this->convertToReturnType($row, $returnType);
4✔
323

324
            $this->tempReturnType = $returnType;
4✔
325
        }
326

327
        return $row;
36✔
328
    }
329

330
    protected function doInsert(array $row)
331
    {
332
        $escape       = $this->escape;
105✔
333
        $this->escape = [];
105✔
334

335
        // Require non-empty primaryKey when
336
        // not using auto-increment feature
337
        if (! $this->useAutoIncrement) {
105✔
338
            if (! isset($row[$this->primaryKey])) {
16✔
339
                throw DataException::forEmptyPrimaryKey('insert');
2✔
340
            }
341

342
            // Validate the primary key value (arrays not allowed for insert)
343
            $this->validateID($row[$this->primaryKey], false);
14✔
344
        }
345

346
        $builder = $this->builder();
94✔
347

348
        // Must use the set() method to ensure to set the correct escape flag
349
        foreach ($row as $key => $val) {
94✔
350
            $builder->set($key, $val, $escape[$key] ?? null);
93✔
351
        }
352

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

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

377
            $result = $this->db->query($sql);
1✔
378
        } else {
379
            $result = $builder->insert();
93✔
380
        }
381

382
        // If insertion succeeded then save the insert ID
383
        if ($result) {
94✔
384
            $this->insertID = $this->useAutoIncrement ? $this->db->insertID() : $row[$this->primaryKey];
91✔
385
        }
386

387
        return $result;
94✔
388
    }
389

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

400
                // Validate the primary key value
401
                $this->validateID($row[$this->primaryKey], false);
11✔
402
            }
403
        }
404

405
        return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize);
12✔
406
    }
407

408
    protected function doUpdate($id = null, $row = null): bool
409
    {
410
        $escape       = $this->escape;
47✔
411
        $this->escape = [];
47✔
412

413
        $builder = $this->builder();
47✔
414

415
        if (is_array($id) && $id !== []) {
47✔
416
            $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
39✔
417
        }
418

419
        // Must use the set() method to ensure to set the correct escape flag
420
        foreach ($row as $key => $val) {
47✔
421
            $builder->set($key, $val, $escape[$key] ?? null);
47✔
422
        }
423

424
        if ($builder->getCompiledQBWhere() === []) {
47✔
425
            throw new DatabaseException(
1✔
426
                'Updates are not allowed unless they contain a "where" or "like" clause.',
1✔
427
            );
1✔
428
        }
429

430
        return $builder->update();
46✔
431
    }
432

433
    protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
434
    {
435
        return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize);
8✔
436
    }
437

438
    protected function doDelete($id = null, bool $purge = false)
439
    {
440
        $set     = [];
42✔
441
        $builder = $this->builder();
42✔
442

443
        if (is_array($id) && $id !== []) {
42✔
444
            $builder = $builder->whereIn($this->primaryKey, $id);
28✔
445
        }
446

447
        if ($this->useSoftDeletes && ! $purge) {
42✔
448
            if ($builder->getCompiledQBWhere() === []) {
31✔
449
                throw new DatabaseException(
3✔
450
                    'Deletes are not allowed unless they contain a "where" or "like" clause.',
3✔
451
                );
3✔
452
            }
453

454
            $builder->where($this->deletedField);
28✔
455

456
            $set[$this->deletedField] = $this->setDate();
28✔
457

458
            if ($this->useTimestamps && $this->updatedField !== '') {
27✔
459
                $set[$this->updatedField] = $this->setDate();
1✔
460
            }
461

462
            return $builder->update($set);
27✔
463
        }
464

465
        return $builder->delete();
11✔
466
    }
467

468
    protected function doPurgeDeleted()
469
    {
470
        return $this->builder()
1✔
471
            ->where($this->table . '.' . $this->deletedField . ' IS NOT NULL')
1✔
472
            ->delete();
1✔
473
    }
474

475
    protected function doOnlyDeleted()
476
    {
477
        $this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL');
2✔
478
    }
479

480
    protected function doReplace(?array $row = null, bool $returnSQL = false)
481
    {
482
        return $this->builder()->testMode($returnSQL)->replace($row);
2✔
483
    }
484

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

497
        if ((int) $error['code'] === 0) {
2✔
498
            return [];
2✔
499
        }
500

501
        return [$this->db::class => $error['message']];
×
502
    }
503

504
    public function getIdValue($row)
505
    {
506
        if (is_object($row)) {
41✔
507
            // Get the raw or mapped primary key value of the Entity.
508
            if ($row instanceof Entity && $row->{$this->primaryKey} !== null) {
28✔
509
                $cast = $row->cast();
9✔
510

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

514
                $primaryKey = $row->{$this->primaryKey};
9✔
515

516
                // Restore Entity casting setting.
517
                $row->cast($cast);
9✔
518

519
                return $primaryKey;
9✔
520
            }
521

522
            if (! $row instanceof Entity && isset($row->{$this->primaryKey})) {
20✔
523
                return $row->{$this->primaryKey};
15✔
524
            }
525
        }
526

527
        if (is_array($row) && isset($row[$this->primaryKey])) {
19✔
528
            return $row[$this->primaryKey];
6✔
529
        }
530

531
        return null;
13✔
532
    }
533

534
    public function countAllResults(bool $reset = true, bool $test = false)
535
    {
536
        $this->prepareSoftDeleteQuery($reset);
17✔
537

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

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

550
        return $this->builder()->testMode($test)->explain($reset);
2✔
551
    }
552

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

562
        return $this->builder()->testMode($test)->exists($reset);
1✔
563
    }
564

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

574
        return $this->builder()->testMode($test)->doesntExist($reset);
1✔
575
    }
576

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

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

594
    /**
595
     * Iterates over the result set in chunks of the specified size.
596
     *
597
     * @param int $size The number of records to retrieve in each chunk.
598
     *
599
     * @return Generator<list<array<string, string>>|list<object>>
600
     */
601
    private function iterateChunks(int $size): Generator
602
    {
603
        $this->assertValidChunkSize($size);
12✔
604

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

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

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

616
            $rows = $rows->getResult($this->tempReturnType);
6✔
617

618
            $offset += $size;
6✔
619

620
            if ($rows === []) {
6✔
621
                continue;
×
622
            }
623

624
            yield $rows;
6✔
625
        }
626
    }
627

628
    /**
629
     * Iterates over the result set in chunks of the specified size ordered by the primary key.
630
     *
631
     * @param int $size The number of records to retrieve in each chunk.
632
     *
633
     * @return Generator<list<array<string, string>>|list<object>>
634
     */
635
    private function iterateChunksById(int $size): Generator
636
    {
637
        $this->assertValidChunkSize($size);
20✔
638

639
        if ($this->primaryKey === '') {
17✔
640
            throw new InvalidArgumentException('ID-based chunking requires a primary key.');
1✔
641
        }
642

643
        $builder             = clone $this->builder();
16✔
644
        $qualifiedPrimaryKey = $this->table . '.' . $this->primaryKey;
16✔
645
        $lastPrimaryKey      = null;
16✔
646
        $hasLastPrimaryKey   = false;
16✔
647

648
        if ($builder->QBOrderBy !== []) {
16✔
649
            throw new InvalidArgumentException('ID-based chunking cannot be used with orderBy().');
1✔
650
        }
651

652
        if ($builder->QBGroupBy !== []) {
15✔
653
            throw new InvalidArgumentException('ID-based chunking cannot be used with groupBy().');
1✔
654
        }
655

656
        if ($builder->hasLimitOffsetOrUnion()) {
14✔
657
            throw new InvalidArgumentException('ID-based chunking cannot be used with limit(), offset() or union().');
3✔
658
        }
659

660
        while (true) {
11✔
661
            $chunkBuilder = clone $builder;
11✔
662

663
            if ($this->tempUseSoftDeletes) {
11✔
664
                $chunkBuilder->where($this->table . '.' . $this->deletedField, null);
9✔
665
            }
666

667
            if ($hasLastPrimaryKey) {
11✔
668
                $chunkBuilder->where($qualifiedPrimaryKey . ' >', $lastPrimaryKey);
7✔
669
            }
670

671
            $rows = $chunkBuilder
11✔
672
                ->orderBy($qualifiedPrimaryKey, 'ASC')
11✔
673
                ->get($size);
11✔
674

675
            if (! $rows) {
11✔
NEW
676
                throw DataException::forEmptyDataset('chunkById');
×
677
            }
678

679
            $rows = $rows->getResult($this->tempReturnType);
11✔
680

681
            if ($rows === []) {
11✔
682
                return;
6✔
683
            }
684

685
            $lastPrimaryKey = $this->getIdValue($rows[array_key_last($rows)]);
11✔
686

687
            if ($lastPrimaryKey === null) {
11✔
688
                throw new InvalidArgumentException('The primary key must be selected for ID-based chunking.');
1✔
689
            }
690

691
            $hasLastPrimaryKey = true;
10✔
692

693
            yield $rows;
10✔
694

695
            if (count($rows) < $size) {
8✔
696
                return;
2✔
697
            }
698
        }
699
    }
700

701
    /**
702
     * Asserts the chunk size is valid.
703
     */
704
    private function assertValidChunkSize(int $size): void
705
    {
706
        if ($size <= 0) {
32✔
707
            throw new InvalidArgumentException('$size must be a positive integer.');
7✔
708
        }
709
    }
710

711
    /**
712
     * {@inheritDoc}
713
     */
714
    public function chunk(int $size, Closure $userFunc)
715
    {
716
        foreach ($this->iterateChunks($size) as $rows) {
6✔
717
            foreach ($rows as $row) {
3✔
718
                if ($userFunc($row) === false) {
3✔
719
                    return;
1✔
720
                }
721
            }
722
        }
723
    }
724

725
    /**
726
     * {@inheritDoc}
727
     */
728
    public function chunkById(int $size, Closure $userFunc)
729
    {
730
        foreach ($this->iterateChunksById($size) as $rows) {
17✔
731
            foreach ($rows as $row) {
8✔
732
                if ($userFunc($row) === false) {
8✔
733
                    return;
1✔
734
                }
735
            }
736
        }
737
    }
738

739
    /**
740
     * {@inheritDoc}
741
     */
742
    public function chunkRows(int $size, Closure $userFunc): void
743
    {
744
        foreach ($this->iterateChunks($size) as $rows) {
6✔
745
            if ($userFunc($rows) === false) {
3✔
746
                return;
1✔
747
            }
748
        }
749
    }
750

751
    /**
752
     * {@inheritDoc}
753
     */
754
    public function chunkRowsById(int $size, Closure $userFunc): void
755
    {
756
        foreach ($this->iterateChunksById($size) as $rows) {
3✔
757
            if ($userFunc($rows) === false) {
2✔
758
                return;
1✔
759
            }
760
        }
761
    }
762

763
    /**
764
     * Provides a shared instance of the Query Builder.
765
     *
766
     * @param non-empty-string|null $table
767
     *
768
     * @return BaseBuilder
769
     *
770
     * @throws ModelException
771
     */
772
    public function builder(?string $table = null)
773
    {
774
        // Check for an existing Builder
775
        if ($this->builder instanceof BaseBuilder) {
254✔
776
            // Make sure the requested table matches the builder
777
            if ((string) $table !== '' && $this->builder->getTable() !== $table) {
152✔
778
                return $this->db->table($table);
1✔
779
            }
780

781
            return $this->builder;
152✔
782
        }
783

784
        // We're going to force a primary key to exist
785
        // so we don't have overly convoluted code,
786
        // and future features are likely to require them.
787
        if ($this->primaryKey === '') {
254✔
788
            throw ModelException::forNoPrimaryKey(static::class);
1✔
789
        }
790

791
        $table = ((string) $table === '') ? $this->table : $table;
253✔
792

793
        // Ensure we have a good db connection
794
        if (! $this->db instanceof BaseConnection) {
253✔
795
            $this->db = Database::connect($this->DBGroup);
×
796
        }
797

798
        $builder = $this->db->table($table);
253✔
799

800
        // Only consider it "shared" if the table is correct
801
        if ($table === $this->table) {
253✔
802
            $this->builder = $builder;
253✔
803
        }
804

805
        return $builder;
253✔
806
    }
807

808
    /**
809
     * Captures the builder's set() method so that we can validate the
810
     * data here. This allows it to be used with any of the other
811
     * builder methods and still get validated data, like replace.
812
     *
813
     * @param object|row_array|string           $key    Field name, or an array of field/value pairs, or an object
814
     * @param bool|float|int|object|string|null $value  Field value, if $key is a single field
815
     * @param bool|null                         $escape Whether to escape values
816
     *
817
     * @return $this
818
     */
819
    public function set($key, $value = '', ?bool $escape = null)
820
    {
821
        if (is_object($key)) {
12✔
822
            $key = $key instanceof stdClass ? (array) $key : $this->objectToArray($key);
2✔
823
        }
824

825
        $data = is_array($key) ? $key : [$key => $value];
12✔
826

827
        foreach (array_keys($data) as $k) {
12✔
828
            $this->tempData['escape'][$k] = $escape;
12✔
829
        }
830

831
        $this->tempData['data'] = array_merge($this->tempData['data'] ?? [], $data);
12✔
832

833
        return $this;
12✔
834
    }
835

836
    protected function shouldUpdate($row): bool
837
    {
838
        if (parent::shouldUpdate($row) === false) {
30✔
839
            return false;
13✔
840
        }
841

842
        if ($this->useAutoIncrement === true) {
19✔
843
            return true;
16✔
844
        }
845

846
        // When useAutoIncrement feature is disabled, check
847
        // in the database if given record already exists
848
        return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1;
3✔
849
    }
850

851
    public function insert($row = null, bool $returnID = true)
852
    {
853
        if (isset($this->tempData['data'])) {
133✔
854
            if ($row === null) {
2✔
855
                $row = $this->tempData['data'];
1✔
856
            } else {
857
                $row = $this->transformDataToArray($row, 'insert');
1✔
858
                $row = array_merge($this->tempData['data'], $row);
1✔
859
            }
860
        }
861

862
        $this->escape   = $this->tempData['escape'] ?? [];
133✔
863
        $this->tempData = [];
133✔
864

865
        return parent::insert($row, $returnID);
133✔
866
    }
867

868
    protected function doProtectFieldsForInsert(array $row): array
869
    {
870
        if (! $this->protectFields) {
136✔
871
            return $row;
9✔
872
        }
873

874
        if ($this->allowedFields === []) {
127✔
875
            throw DataException::forInvalidAllowedFields(static::class);
1✔
876
        }
877

878
        $this->ensureNoDisallowedFields($row, $this->useAutoIncrement === false ? [$this->primaryKey] : []);
126✔
879

880
        foreach (array_keys($row) as $key) {
122✔
881
            // Do not remove the non-auto-incrementing primary key data.
882
            if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
121✔
883
                continue;
26✔
884
            }
885

886
            if (! in_array($key, $this->allowedFields, true)) {
121✔
887
                unset($row[$key]);
26✔
888
            }
889
        }
890

891
        return $row;
122✔
892
    }
893

894
    protected function doProtectFieldsForUpdate(array $row): array
895
    {
896
        $row = $this->doProtectInsertOnlyFieldsForUpdate($row, [$this->primaryKey]);
53✔
897
        $this->ensureNoDisallowedFields($row, [$this->primaryKey]);
50✔
898

899
        return $this->doProtectFields($row);
49✔
900
    }
901

902
    /**
903
     * Finds the first row matching attributes or inserts a new row.
904
     *
905
     * Note: without a DB unique constraint, this is not race-safe.
906
     *
907
     * @param array<string, mixed>|object $attributes
908
     * @param array<string, mixed>|object $values
909
     *
910
     * @return array<string, mixed>|false|object
911
     */
912
    public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object
913
    {
914
        if (is_object($attributes)) {
13✔
915
            $attributes = $this->transformDataToArray($attributes, 'insert');
2✔
916
        }
917

918
        if ($attributes === []) {
13✔
919
            throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
1✔
920
        }
921

922
        $row = $this->where($attributes)->first();
12✔
923
        if ($row !== null) {
12✔
924
            return $row;
4✔
925
        }
926

927
        if (is_object($values)) {
8✔
928
            $values = $this->transformDataToArray($values, 'insert');
1✔
929
        }
930

931
        $data = array_merge($attributes, $values);
8✔
932

933
        try {
934
            $id = $this->insert($data);
8✔
935
        } catch (UniqueConstraintViolationException) {
1✔
936
            return $this->where($attributes)->first() ?? false;
1✔
937
        }
938

939
        if ($id === false) {
7✔
940
            if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
3✔
941
                return $this->where($attributes)->first() ?? false;
1✔
942
            }
943

944
            return false;
2✔
945
        }
946

947
        return $this->where($this->primaryKey, $id)->first() ?? false;
4✔
948
    }
949

950
    public function update($id = null, $row = null): bool
951
    {
952
        if (isset($this->tempData['data'])) {
72✔
953
            if ($row === null) {
8✔
954
                $row = $this->tempData['data'];
5✔
955
            } else {
956
                $row = $this->transformDataToArray($row, 'update');
3✔
957
                $row = array_merge($this->tempData['data'], $row);
3✔
958
            }
959
        }
960

961
        $this->escape   = $this->tempData['escape'] ?? [];
72✔
962
        $this->tempData = [];
72✔
963

964
        return parent::update($id, $row);
72✔
965
    }
966

967
    /**
968
     * Provides/instantiates the builder/db connection and model's table/primary key names and return type.
969
     *
970
     * @return mixed
971
     */
972
    public function __get(string $name)
973
    {
974
        if (parent::__isset($name)) {
50✔
975
            return parent::__get($name);
50✔
976
        }
977

978
        return $this->builder()->{$name} ?? null;
1✔
979
    }
980

981
    /**
982
     * Checks for the existence of properties across this model, builder, and db connection.
983
     */
984
    public function __isset(string $name): bool
985
    {
986
        if (parent::__isset($name)) {
47✔
987
            return true;
47✔
988
        }
989

990
        return isset($this->builder()->{$name});
1✔
991
    }
992

993
    /**
994
     * Provides direct access to method in the builder (if available)
995
     * and the database connection.
996
     *
997
     * @return mixed
998
     */
999
    public function __call(string $name, array $params)
1000
    {
1001
        $builder = $this->builder();
74✔
1002
        $result  = null;
74✔
1003

1004
        if (method_exists($this->db, $name)) {
74✔
1005
            $result = $this->db->{$name}(...$params);
2✔
1006
        } elseif (method_exists($builder, $name)) {
73✔
1007
            $this->checkBuilderMethod($name);
72✔
1008

1009
            $result = $builder->{$name}(...$params);
70✔
1010
        } else {
1011
            throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
1✔
1012
        }
1013

1014
        if ($result instanceof BaseBuilder) {
71✔
1015
            return $this;
70✔
1016
        }
1017

1018
        return $result;
2✔
1019
    }
1020

1021
    /**
1022
     * Checks the Builder method name that should not be used in the Model.
1023
     */
1024
    private function checkBuilderMethod(string $name): void
1025
    {
1026
        if (in_array($name, $this->builderMethodsNotAvailable, true)) {
72✔
1027
            throw ModelException::forMethodNotAvailable(static::class, $name . '()');
2✔
1028
        }
1029
    }
1030
}
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