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

codeigniter4 / CodeIgniter4 / 26446923095

26 May 2026 10:29AM UTC coverage: 88.485% (+0.02%) from 88.462%
26446923095

Pull #10221

github

web-flow
Merge 22087e1e2 into aad4c9dcc
Pull Request #10221: feat: add Fetch Metadata CSRF protection

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

104 existing lines in 9 files now uncovered.

24228 of 27381 relevant lines covered (88.48%)

221.93 hits per line

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

98.57
/system/Model.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter;
15

16
use Closure;
17
use CodeIgniter\Database\BaseBuilder;
18
use CodeIgniter\Database\BaseConnection;
19
use CodeIgniter\Database\ConnectionInterface;
20
use CodeIgniter\Database\Exceptions\DatabaseException;
21
use CodeIgniter\Database\Exceptions\DataException;
22
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
23
use CodeIgniter\Entity\Entity;
24
use CodeIgniter\Exceptions\BadMethodCallException;
25
use CodeIgniter\Exceptions\InvalidArgumentException;
26
use CodeIgniter\Exceptions\ModelException;
27
use CodeIgniter\Validation\ValidationInterface;
28
use Config\Database;
29
use Config\Feature;
30
use Generator;
31
use stdClass;
32

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

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

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

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

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

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

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

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

170
        $this->db = $db;
418✔
171

172
        parent::__construct($validation);
418✔
173
    }
174

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

184
        return $this;
1✔
185
    }
186

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

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

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

201
        $row  = null;
55✔
202
        $rows = [];
55✔
203

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

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

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

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

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

231
            return $rows;
3✔
232
        }
233

234
        if ($singleton) {
37✔
235
            return $row;
29✔
236
        }
237

238
        return $rows;
9✔
239
    }
240

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

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

258
        $builder = $this->builder();
22✔
259

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

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

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

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

279
            $this->tempReturnType = $returnType;
5✔
280
        }
281

282
        return $results;
22✔
283
    }
284

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

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

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

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

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

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

318
            $this->tempReturnType = $returnType;
4✔
319
        }
320

321
        return $row;
36✔
322
    }
323

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

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

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

340
        $builder = $this->builder();
91✔
341

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

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

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

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

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

381
        return $result;
91✔
382
    }
383

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

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

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

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

407
        $builder = $this->builder();
38✔
408

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

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

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

424
        return $builder->update();
37✔
425
    }
426

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

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

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

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

448
            $builder->where($this->deletedField);
24✔
449

450
            $set[$this->deletedField] = $this->setDate();
24✔
451

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

456
            return $builder->update($set);
23✔
457
        }
458

459
        return $builder->delete();
11✔
460
    }
461

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

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

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

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

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

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

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

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

508
                $primaryKey = $row->{$this->primaryKey};
9✔
509

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

513
                return $primaryKey;
9✔
514
            }
515

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

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

525
        return null;
11✔
526
    }
527

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

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

535
    /**
536
     * Determines whether the current Model query would return at least one row.
537
     *
538
     * @return bool|string Returns a SQL string if in test mode.
539
     */
540
    public function exists(bool $reset = true, bool $test = false)
541
    {
542
        $this->prepareSoftDeleteQuery($reset);
1✔
543

544
        return $this->builder()->testMode($test)->exists($reset);
1✔
545
    }
546

547
    /**
548
     * Determines whether the current Model query would not return any rows.
549
     *
550
     * @return bool|string Returns a SQL string if in test mode.
551
     */
552
    public function doesntExist(bool $reset = true, bool $test = false)
553
    {
554
        $this->prepareSoftDeleteQuery($reset);
1✔
555

556
        return $this->builder()->testMode($test)->doesntExist($reset);
1✔
557
    }
558

559
    /**
560
     * Applies the Model soft-delete constraint before terminal Builder operations.
561
     */
562
    private function prepareSoftDeleteQuery(bool $reset): void
563
    {
564
        if ($this->tempUseSoftDeletes) {
19✔
565
            $this->builder()->where($this->table . '.' . $this->deletedField, null);
8✔
566
        }
567

568
        // When $reset === false, the $tempUseSoftDeletes will be
569
        // dependent on $useSoftDeletes value because we don't
570
        // want to add the same "where" condition for the second time.
571
        $this->tempUseSoftDeletes = $reset
19✔
572
            ? $this->useSoftDeletes
12✔
573
            : ($this->useSoftDeletes ? false : $this->useSoftDeletes);
12✔
574
    }
575

576
    /**
577
     * Iterates over the result set in chunks of the specified size.
578
     *
579
     * @param int $size The number of records to retrieve in each chunk.
580
     *
581
     * @return Generator<list<array<string, string>>|list<object>>
582
     */
583
    private function iterateChunks(int $size): Generator
584
    {
585
        if ($size <= 0) {
12✔
586
            throw new InvalidArgumentException('$size must be a positive integer.');
4✔
587
        }
588

589
        $total  = $this->builder()->countAllResults(false);
8✔
590
        $offset = 0;
8✔
591

592
        while ($offset < $total) {
8✔
593
            $builder = clone $this->builder();
6✔
594
            $rows    = $builder->get($size, $offset);
6✔
595

596
            if (! $rows) {
6✔
UNCOV
597
                throw DataException::forEmptyDataset('chunk');
×
598
            }
599

600
            $rows = $rows->getResult($this->tempReturnType);
6✔
601

602
            $offset += $size;
6✔
603

604
            if ($rows === []) {
6✔
UNCOV
605
                continue;
×
606
            }
607

608
            yield $rows;
6✔
609
        }
610
    }
611

612
    /**
613
     * {@inheritDoc}
614
     */
615
    public function chunk(int $size, Closure $userFunc)
616
    {
617
        foreach ($this->iterateChunks($size) as $rows) {
6✔
618
            foreach ($rows as $row) {
3✔
619
                if ($userFunc($row) === false) {
3✔
620
                    return;
1✔
621
                }
622
            }
623
        }
624
    }
625

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

638
    /**
639
     * Provides a shared instance of the Query Builder.
640
     *
641
     * @param non-empty-string|null $table
642
     *
643
     * @return BaseBuilder
644
     *
645
     * @throws ModelException
646
     */
647
    public function builder(?string $table = null)
648
    {
649
        // Check for an existing Builder
650
        if ($this->builder instanceof BaseBuilder) {
219✔
651
            // Make sure the requested table matches the builder
652
            if ((string) $table !== '' && $this->builder->getTable() !== $table) {
135✔
653
                return $this->db->table($table);
1✔
654
            }
655

656
            return $this->builder;
135✔
657
        }
658

659
        // We're going to force a primary key to exist
660
        // so we don't have overly convoluted code,
661
        // and future features are likely to require them.
662
        if ($this->primaryKey === '') {
219✔
663
            throw ModelException::forNoPrimaryKey(static::class);
1✔
664
        }
665

666
        $table = ((string) $table === '') ? $this->table : $table;
218✔
667

668
        // Ensure we have a good db connection
669
        if (! $this->db instanceof BaseConnection) {
218✔
UNCOV
670
            $this->db = Database::connect($this->DBGroup);
×
671
        }
672

673
        $builder = $this->db->table($table);
218✔
674

675
        // Only consider it "shared" if the table is correct
676
        if ($table === $this->table) {
218✔
677
            $this->builder = $builder;
218✔
678
        }
679

680
        return $builder;
218✔
681
    }
682

683
    /**
684
     * Captures the builder's set() method so that we can validate the
685
     * data here. This allows it to be used with any of the other
686
     * builder methods and still get validated data, like replace.
687
     *
688
     * @param object|row_array|string           $key    Field name, or an array of field/value pairs, or an object
689
     * @param bool|float|int|object|string|null $value  Field value, if $key is a single field
690
     * @param bool|null                         $escape Whether to escape values
691
     *
692
     * @return $this
693
     */
694
    public function set($key, $value = '', ?bool $escape = null)
695
    {
696
        if (is_object($key)) {
10✔
697
            $key = $key instanceof stdClass ? (array) $key : $this->objectToArray($key);
2✔
698
        }
699

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

702
        foreach (array_keys($data) as $k) {
10✔
703
            $this->tempData['escape'][$k] = $escape;
10✔
704
        }
705

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

708
        return $this;
10✔
709
    }
710

711
    protected function shouldUpdate($row): bool
712
    {
713
        if (parent::shouldUpdate($row) === false) {
27✔
714
            return false;
12✔
715
        }
716

717
        if ($this->useAutoIncrement === true) {
17✔
718
            return true;
14✔
719
        }
720

721
        // When useAutoIncrement feature is disabled, check
722
        // in the database if given record already exists
723
        return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1;
3✔
724
    }
725

726
    public function insert($row = null, bool $returnID = true)
727
    {
728
        if (isset($this->tempData['data'])) {
127✔
729
            if ($row === null) {
2✔
730
                $row = $this->tempData['data'];
1✔
731
            } else {
732
                $row = $this->transformDataToArray($row, 'insert');
1✔
733
                $row = array_merge($this->tempData['data'], $row);
1✔
734
            }
735
        }
736

737
        $this->escape   = $this->tempData['escape'] ?? [];
127✔
738
        $this->tempData = [];
127✔
739

740
        return parent::insert($row, $returnID);
127✔
741
    }
742

743
    protected function doProtectFieldsForInsert(array $row): array
744
    {
745
        if (! $this->protectFields) {
128✔
746
            return $row;
9✔
747
        }
748

749
        if ($this->allowedFields === []) {
119✔
750
            throw DataException::forInvalidAllowedFields(static::class);
1✔
751
        }
752

753
        foreach (array_keys($row) as $key) {
118✔
754
            // Do not remove the non-auto-incrementing primary key data.
755
            if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
117✔
756
                continue;
25✔
757
            }
758

759
            if (! in_array($key, $this->allowedFields, true)) {
117✔
760
                unset($row[$key]);
25✔
761
            }
762
        }
763

764
        return $row;
118✔
765
    }
766

767
    /**
768
     * Finds the first row matching attributes or inserts a new row.
769
     *
770
     * Note: without a DB unique constraint, this is not race-safe.
771
     *
772
     * @param array<string, mixed>|object $attributes
773
     * @param array<string, mixed>|object $values
774
     *
775
     * @return array<string, mixed>|false|object
776
     */
777
    public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object
778
    {
779
        if (is_object($attributes)) {
13✔
780
            $attributes = $this->transformDataToArray($attributes, 'insert');
2✔
781
        }
782

783
        if ($attributes === []) {
13✔
784
            throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
1✔
785
        }
786

787
        $row = $this->where($attributes)->first();
12✔
788
        if ($row !== null) {
12✔
789
            return $row;
4✔
790
        }
791

792
        if (is_object($values)) {
8✔
793
            $values = $this->transformDataToArray($values, 'insert');
1✔
794
        }
795

796
        $data = array_merge($attributes, $values);
8✔
797

798
        try {
799
            $id = $this->insert($data);
8✔
800
        } catch (UniqueConstraintViolationException) {
1✔
801
            return $this->where($attributes)->first() ?? false;
1✔
802
        }
803

804
        if ($id === false) {
7✔
805
            if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
3✔
806
                return $this->where($attributes)->first() ?? false;
1✔
807
            }
808

809
            return false;
2✔
810
        }
811

812
        return $this->where($this->primaryKey, $id)->first() ?? false;
4✔
813
    }
814

815
    public function update($id = null, $row = null): bool
816
    {
817
        if (isset($this->tempData['data'])) {
58✔
818
            if ($row === null) {
6✔
819
                $row = $this->tempData['data'];
5✔
820
            } else {
821
                $row = $this->transformDataToArray($row, 'update');
1✔
822
                $row = array_merge($this->tempData['data'], $row);
1✔
823
            }
824
        }
825

826
        $this->escape   = $this->tempData['escape'] ?? [];
58✔
827
        $this->tempData = [];
58✔
828

829
        return parent::update($id, $row);
58✔
830
    }
831

832
    protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
833
    {
834
        return parent::objectToRawArray($object, $onlyChanged);
25✔
835
    }
836

837
    /**
838
     * Provides/instantiates the builder/db connection and model's table/primary key names and return type.
839
     *
840
     * @return array<int|string, mixed>|BaseBuilder|bool|float|int|object|string|null
841
     */
842
    public function __get(string $name)
843
    {
844
        if (parent::__isset($name)) {
50✔
845
            return parent::__get($name);
50✔
846
        }
847

848
        return $this->builder()->{$name} ?? null;
1✔
849
    }
850

851
    /**
852
     * Checks for the existence of properties across this model, builder, and db connection.
853
     */
854
    public function __isset(string $name): bool
855
    {
856
        if (parent::__isset($name)) {
47✔
857
            return true;
47✔
858
        }
859

860
        return isset($this->builder()->{$name});
1✔
861
    }
862

863
    /**
864
     * Provides direct access to method in the builder (if available)
865
     * and the database connection.
866
     *
867
     * @return $this|array<int|string, mixed>|BaseBuilder|bool|float|int|object|string|null
868
     */
869
    public function __call(string $name, array $params)
870
    {
871
        $builder = $this->builder();
62✔
872
        $result  = null;
62✔
873

874
        if (method_exists($this->db, $name)) {
62✔
875
            $result = $this->db->{$name}(...$params);
2✔
876
        } elseif (method_exists($builder, $name)) {
61✔
877
            $this->checkBuilderMethod($name);
60✔
878

879
            $result = $builder->{$name}(...$params);
58✔
880
        } else {
881
            throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
1✔
882
        }
883

884
        if ($result instanceof BaseBuilder) {
59✔
885
            return $this;
58✔
886
        }
887

888
        return $result;
2✔
889
    }
890

891
    /**
892
     * Checks the Builder method name that should not be used in the Model.
893
     */
894
    private function checkBuilderMethod(string $name): void
895
    {
896
        if (in_array($name, $this->builderMethodsNotAvailable, true)) {
60✔
897
            throw ModelException::forMethodNotAvailable(static::class, $name . '()');
2✔
898
        }
899
    }
900
}
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