3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
16
use BadMethodCallException;
18
use CodeIgniter\Database\BaseBuilder;
19
use CodeIgniter\Database\BaseConnection;
20
use CodeIgniter\Database\BaseResult;
21
use CodeIgniter\Database\ConnectionInterface;
22
use CodeIgniter\Database\Exceptions\DatabaseException;
23
use CodeIgniter\Database\Exceptions\DataException;
24
use CodeIgniter\Database\Query;
25
use CodeIgniter\Entity\Entity;
26
use CodeIgniter\Exceptions\ModelException;
27
use CodeIgniter\Validation\ValidationInterface;
30
use ReflectionException;
34
* The Model class extends BaseModel and provides additional
35
* convenient features that makes working with a SQL database
39
* - automatically connect to database
40
* - allow intermingling calls to the builder
41
* - removes the need to use Result object directly in most cases
43
* @property-read BaseConnection $db
45
* @method $this groupBy($by, ?bool $escape = null)
46
* @method $this groupEnd()
47
* @method $this groupStart()
48
* @method $this having($key, $value = null, ?bool $escape = null)
49
* @method $this havingGroupEnd()
50
* @method $this havingGroupStart()
51
* @method $this havingIn(?string $key = null, $values = null, ?bool $escape = null)
52
* @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
53
* @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
54
* @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
55
* @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
56
* @method $this limit(?int $value = null, ?int $offset = 0)
57
* @method $this notGroupStart()
58
* @method $this notHavingGroupStart()
59
* @method $this notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
60
* @method $this notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
61
* @method $this offset(int $offset)
62
* @method $this orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
63
* @method $this orGroupStart()
64
* @method $this orHaving($key, $value = null, ?bool $escape = null)
65
* @method $this orHavingGroupStart()
66
* @method $this orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
67
* @method $this orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
68
* @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
69
* @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
70
* @method $this orNotGroupStart()
71
* @method $this orNotHavingGroupStart()
72
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
73
* @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
74
* @method $this orWhere($key, $value = null, ?bool $escape = null)
75
* @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
76
* @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
77
* @method $this select($select = '*', ?bool $escape = null)
78
* @method $this selectAvg(string $select = '', string $alias = '')
79
* @method $this selectCount(string $select = '', string $alias = '')
80
* @method $this selectMax(string $select = '', string $alias = '')
81
* @method $this selectMin(string $select = '', string $alias = '')
82
* @method $this selectSum(string $select = '', string $alias = '')
83
* @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
84
* @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
85
* @method $this where($key, $value = null, ?bool $escape = null)
86
* @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
87
* @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
89
* @phpstan-import-type row_array from BaseModel
91
class Model extends BaseModel
94
* Name of database table
101
* The table's primary key.
105
protected $primaryKey = 'id';
108
* Whether primary key uses auto increment.
112
protected $useAutoIncrement = true;
115
* Query Builder object
117
* @var BaseBuilder|null
122
* Holds information passed in via 'set'
123
* so that we can capture it (not the builder)
124
* and ensure it gets validated first.
126
* @var array{escape: array, data: array}|array{}
127
* @phpstan-var array{escape: array<int|string, bool|null>, data: row_array}|array{}
129
protected $tempData = [];
132
* Escape array that maps usage of escape
133
* flag for every parameter.
137
protected $escape = [];
140
* Builder method names that should not be used in the Model.
142
* @var list<string> method name
144
private array $builderMethodsNotAvailable = [
150
public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
153
* @var BaseConnection|null $db
155
$db ??= Database::connect($this->DBGroup);
159
parent::__construct($validation);
163
* Specify the table associated with a model
165
* @param string $table Table
169
public function setTable(string $table)
171
$this->table = $table;
177
* Fetches the row(s) of database from $this->table with a primary key
179
* This method works only with dbCalls.
181
* @param bool $singleton Single or multiple results
182
* @param array|int|string|null $id One primary key or an array of primary keys
184
* @return array|object|null The resulting row of data, or null.
185
* @phpstan-return ($singleton is true ? row_array|null|object : list<row_array|object>)
187
protected function doFind(bool $singleton, $id = null)
189
$builder = $this->builder();
191
$useCast = $this->useCasts();
193
$returnType = $this->tempReturnType;
197
if ($this->tempUseSoftDeletes) {
198
$builder->where($this->table . '.' . $this->deletedField, null);
205
$rows = $builder->whereIn($this->table . '.' . $this->primaryKey, $id)
207
->getResult($this->tempReturnType);
208
} elseif ($singleton) {
209
$row = $builder->where($this->table . '.' . $this->primaryKey, $id)
211
->getFirstRow($this->tempReturnType);
213
$rows = $builder->get()->getResult($this->tempReturnType);
217
$this->tempReturnType = $returnType;
224
return $this->convertToReturnType($row, $returnType);
227
foreach ($rows as $i => $row) {
228
$rows[$i] = $this->convertToReturnType($row, $returnType);
242
* Fetches the column of database from $this->table.
243
* This method works only with dbCalls.
245
* @param string $columnName Column Name
247
* @return array|null The resulting row of data, or null if no data found.
248
* @phpstan-return list<row_array>|null
250
protected function doFindColumn(string $columnName)
252
return $this->select($columnName)->asArray()->find();
256
* Works with the current Query Builder instance to return
257
* all results, while optionally limiting them.
258
* This method works only with dbCalls.
260
* @param int|null $limit Limit
261
* @param int $offset Offset
264
* @phpstan-return list<row_array|object>
266
protected function doFindAll(?int $limit = null, int $offset = 0)
268
$limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true;
269
if ($limitZeroAsAll) {
273
$builder = $this->builder();
275
$useCast = $this->useCasts();
277
$returnType = $this->tempReturnType;
281
if ($this->tempUseSoftDeletes) {
282
$builder->where($this->table . '.' . $this->deletedField, null);
285
$results = $builder->limit($limit, $offset)
287
->getResult($this->tempReturnType);
290
foreach ($results as $i => $row) {
291
$results[$i] = $this->convertToReturnType($row, $returnType);
294
$this->tempReturnType = $returnType;
301
* Returns the first row of the result set. Will take any previous
302
* Query Builder calls into account when determining the result set.
303
* This method works only with dbCalls.
305
* @return array|object|null
306
* @phpstan-return row_array|object|null
308
protected function doFirst()
310
$builder = $this->builder();
312
$useCast = $this->useCasts();
314
$returnType = $this->tempReturnType;
318
if ($this->tempUseSoftDeletes) {
319
$builder->where($this->table . '.' . $this->deletedField, null);
320
} elseif ($this->useSoftDeletes && ($builder->QBGroupBy === []) && $this->primaryKey) {
321
$builder->groupBy($this->table . '.' . $this->primaryKey);
324
// Some databases, like PostgreSQL, need order
325
// information to consistently return correct results.
326
if ($builder->QBGroupBy && ($builder->QBOrderBy === []) && $this->primaryKey) {
327
$builder->orderBy($this->table . '.' . $this->primaryKey, 'asc');
330
$row = $builder->limit(1, 0)->get()->getFirstRow($this->tempReturnType);
332
if ($useCast && $row !== null) {
333
$row = $this->convertToReturnType($row, $returnType);
335
$this->tempReturnType = $returnType;
342
* Inserts data into the current table.
343
* This method works only with dbCalls.
345
* @param array $row Row data
346
* @phpstan-param row_array $row
350
protected function doInsert(array $row)
352
$escape = $this->escape;
355
// Require non-empty primaryKey when
356
// not using auto-increment feature
357
if (! $this->useAutoIncrement && ! isset($row[$this->primaryKey])) {
358
throw DataException::forEmptyPrimaryKey('insert');
361
$builder = $this->builder();
363
// Must use the set() method to ensure to set the correct escape flag
364
foreach ($row as $key => $val) {
365
$builder->set($key, $val, $escape[$key] ?? null);
368
if ($this->allowEmptyInserts && $row === []) {
369
$table = $this->db->protectIdentifiers($this->table, true, null, false);
370
if ($this->db->getPlatform() === 'MySQLi') {
371
$sql = 'INSERT INTO ' . $table . ' VALUES ()';
372
} elseif ($this->db->getPlatform() === 'OCI8') {
373
$allFields = $this->db->protectIdentifiers(
375
static fn ($row) => $row->name,
376
$this->db->getFieldData($this->table)
383
'INSERT INTO %s (%s) VALUES (%s)',
385
implode(',', $allFields),
386
substr(str_repeat(',DEFAULT', count($allFields)), 1)
389
$sql = 'INSERT INTO ' . $table . ' DEFAULT VALUES';
392
$result = $this->db->query($sql);
394
$result = $builder->insert();
397
// If insertion succeeded then save the insert ID
399
$this->insertID = ! $this->useAutoIncrement ? $row[$this->primaryKey] : $this->db->insertID();
406
* Compiles batch insert strings and runs the queries, validating each row prior.
407
* This method works only with dbCalls.
409
* @param array|null $set An associative array of insert values
410
* @param bool|null $escape Whether to escape values
411
* @param int $batchSize The size of the batch to run
412
* @param bool $testing True means only number of records is returned, false will execute the query
414
* @return bool|int Number of rows inserted or FALSE on failure
416
protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false)
418
if (is_array($set)) {
419
foreach ($set as $row) {
420
// Require non-empty primaryKey when
421
// not using auto-increment feature
422
if (! $this->useAutoIncrement && ! isset($row[$this->primaryKey])) {
423
throw DataException::forEmptyPrimaryKey('insertBatch');
428
return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize);
432
* Updates a single record in $this->table.
433
* This method works only with dbCalls.
435
* @param array|int|string|null $id
436
* @param array|null $row Row data
437
* @phpstan-param row_array|null $row
439
protected function doUpdate($id = null, $row = null): bool
441
$escape = $this->escape;
444
$builder = $this->builder();
447
$builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
450
// Must use the set() method to ensure to set the correct escape flag
451
foreach ($row as $key => $val) {
452
$builder->set($key, $val, $escape[$key] ?? null);
455
if ($builder->getCompiledQBWhere() === []) {
456
throw new DatabaseException(
457
'Updates are not allowed unless they contain a "where" or "like" clause.'
461
return $builder->update();
465
* Compiles an update string and runs the query
466
* This method works only with dbCalls.
468
* @param array|null $set An associative array of update values
469
* @param string|null $index The where key
470
* @param int $batchSize The size of the batch to run
471
* @param bool $returnSQL True means SQL is returned, false will execute the query
473
* @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
475
* @throws DatabaseException
477
protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
479
return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize);
483
* Deletes a single record from $this->table where $id matches
484
* the table's primaryKey
485
* This method works only with dbCalls.
487
* @param array|int|string|null $id The rows primary key(s)
488
* @param bool $purge Allows overriding the soft deletes setting.
490
* @return bool|string SQL string when testMode
492
* @throws DatabaseException
494
protected function doDelete($id = null, bool $purge = false)
497
$builder = $this->builder();
500
$builder = $builder->whereIn($this->primaryKey, $id);
503
if ($this->useSoftDeletes && ! $purge) {
504
if ($builder->getCompiledQBWhere() === []) {
505
throw new DatabaseException(
506
'Deletes are not allowed unless they contain a "where" or "like" clause.'
510
$builder->where($this->deletedField);
512
$set[$this->deletedField] = $this->setDate();
514
if ($this->useTimestamps && $this->updatedField !== '') {
515
$set[$this->updatedField] = $this->setDate();
518
return $builder->update($set);
521
return $builder->delete();
525
* Permanently deletes all rows that have been marked as deleted
526
* through soft deletes (deleted = 1)
527
* This method works only with dbCalls.
529
* @return bool|string Returns a SQL string if in test mode.
531
protected function doPurgeDeleted()
533
return $this->builder()
534
->where($this->table . '.' . $this->deletedField . ' IS NOT NULL')
539
* Works with the find* methods to return only the rows that
541
* This method works only with dbCalls.
545
protected function doOnlyDeleted()
547
$this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL');
551
* Compiles a replace into string and runs the query
552
* This method works only with dbCalls.
554
* @param array|null $row Data
555
* @phpstan-param row_array|null $row
556
* @param bool $returnSQL Set to true to return Query String
558
* @return BaseResult|false|Query|string
560
protected function doReplace(?array $row = null, bool $returnSQL = false)
562
return $this->builder()->testMode($returnSQL)->replace($row);
566
* Grabs the last error(s) that occurred from the Database connection.
567
* The return array should be in the following format:
568
* ['source' => 'message']
569
* This method works only with dbCalls.
571
* @return array<string, string>
573
protected function doErrors()
575
// $error is always ['code' => string|int, 'message' => string]
576
$error = $this->db->error();
578
if ((int) $error['code'] === 0) {
582
return [$this->db::class => $error['message']];
586
* Returns the id value for the data array or object
588
* @param array|object $row Row data
589
* @phpstan-param row_array|object $row
591
* @return array|int|string|null
593
public function getIdValue($row)
595
if (is_object($row) && isset($row->{$this->primaryKey})) {
596
// Get the raw primary key value of the Entity.
597
if ($row instanceof Entity) {
598
$cast = $row->cast();
600
// Disable Entity casting, because raw primary key value is needed for database.
603
$primaryKey = $row->{$this->primaryKey};
605
// Restore Entity casting setting.
611
return $row->{$this->primaryKey};
614
if (is_array($row) && isset($row[$this->primaryKey])) {
615
return $row[$this->primaryKey];
622
* Loops over records in batches, allowing you to operate on them.
623
* Works with $this->builder to get the Compiled select to
624
* determine the rows to operate on.
625
* This method works only with dbCalls.
629
* @throws DataException
631
public function chunk(int $size, Closure $userFunc)
633
$total = $this->builder()->countAllResults(false);
636
while ($offset <= $total) {
637
$builder = clone $this->builder();
638
$rows = $builder->get($size, $offset);
641
throw DataException::forEmptyDataset('chunk');
644
$rows = $rows->getResult($this->tempReturnType);
652
foreach ($rows as $row) {
653
if ($userFunc($row) === false) {
661
* Override countAllResults to account for soft deleted accounts.
665
public function countAllResults(bool $reset = true, bool $test = false)
667
if ($this->tempUseSoftDeletes) {
668
$this->builder()->where($this->table . '.' . $this->deletedField, null);
671
// When $reset === false, the $tempUseSoftDeletes will be
672
// dependent on $useSoftDeletes value because we don't
673
// want to add the same "where" condition for the second time
674
$this->tempUseSoftDeletes = $reset
675
? $this->useSoftDeletes
676
: ($this->useSoftDeletes ? false : $this->useSoftDeletes);
678
return $this->builder()->testMode($test)->countAllResults($reset);
682
* Provides a shared instance of the Query Builder.
684
* @param non-empty-string|null $table
686
* @return BaseBuilder
688
* @throws ModelException
690
public function builder(?string $table = null)
692
// Check for an existing Builder
693
if ($this->builder instanceof BaseBuilder) {
694
// Make sure the requested table matches the builder
695
if ($table && $this->builder->getTable() !== $table) {
696
return $this->db->table($table);
699
return $this->builder;
702
// We're going to force a primary key to exist
703
// so we don't have overly convoluted code,
704
// and future features are likely to require them.
705
if ($this->primaryKey === '') {
706
throw ModelException::forNoPrimaryKey(static::class);
709
$table = ($table === null || $table === '') ? $this->table : $table;
711
// Ensure we have a good db connection
712
if (! $this->db instanceof BaseConnection) {
713
$this->db = Database::connect($this->DBGroup);
716
$builder = $this->db->table($table);
718
// Only consider it "shared" if the table is correct
719
if ($table === $this->table) {
720
$this->builder = $builder;
727
* Captures the builder's set() method so that we can validate the
728
* data here. This allows it to be used with any of the other
729
* builder methods and still get validated data, like replace.
731
* @param array|object|string $key Field name, or an array of field/value pairs, or an object
732
* @param bool|float|int|object|string|null $value Field value, if $key is a single field
733
* @param bool|null $escape Whether to escape values
737
public function set($key, $value = '', ?bool $escape = null)
739
if (is_object($key)) {
740
$key = $key instanceof stdClass ? (array) $key : $this->objectToArray($key);
743
$data = is_array($key) ? $key : [$key => $value];
745
foreach (array_keys($data) as $k) {
746
$this->tempData['escape'][$k] = $escape;
749
$this->tempData['data'] = array_merge($this->tempData['data'] ?? [], $data);
755
* This method is called on save to determine if entry have to be updated
756
* If this method return false insert operation will be executed
758
* @param array|object $row Data
760
protected function shouldUpdate($row): bool
762
if (parent::shouldUpdate($row) === false) {
766
if ($this->useAutoIncrement === true) {
770
// When useAutoIncrement feature is disabled, check
771
// in the database if given record already exists
772
return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1;
776
* Inserts data into the database. If an object is provided,
777
* it will attempt to convert it to an array.
779
* @param array|object|null $row
780
* @phpstan-param row_array|object|null $row
781
* @param bool $returnID Whether insert ID should be returned or not.
783
* @return bool|int|string
784
* @phpstan-return ($returnID is true ? int|string|false : bool)
786
* @throws ReflectionException
788
public function insert($row = null, bool $returnID = true)
790
if (isset($this->tempData['data'])) {
792
$row = $this->tempData['data'];
794
$row = $this->transformDataToArray($row, 'insert');
795
$row = array_merge($this->tempData['data'], $row);
799
$this->escape = $this->tempData['escape'] ?? [];
800
$this->tempData = [];
802
return parent::insert($row, $returnID);
806
* Ensures that only the fields that are allowed to be inserted are in
809
* @used-by insert() to protect against mass assignment vulnerabilities.
810
* @used-by insertBatch() to protect against mass assignment vulnerabilities.
812
* @param array $row Row data
813
* @phpstan-param row_array $row
815
* @throws DataException
817
protected function doProtectFieldsForInsert(array $row): array
819
if (! $this->protectFields) {
823
if ($this->allowedFields === []) {
824
throw DataException::forInvalidAllowedFields(static::class);
827
foreach (array_keys($row) as $key) {
828
// Do not remove the non-auto-incrementing primary key data.
829
if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
833
if (! in_array($key, $this->allowedFields, true)) {
842
* Updates a single record in the database. If an object is provided,
843
* it will attempt to convert it into an array.
845
* @param array|int|string|null $id
846
* @param array|object|null $row
847
* @phpstan-param row_array|object|null $row
849
* @throws ReflectionException
851
public function update($id = null, $row = null): bool
853
if (isset($this->tempData['data'])) {
855
$row = $this->tempData['data'];
857
$row = $this->transformDataToArray($row, 'update');
858
$row = array_merge($this->tempData['data'], $row);
862
$this->escape = $this->tempData['escape'] ?? [];
863
$this->tempData = [];
865
return parent::update($id, $row);
869
* Takes a class and returns an array of its public and protected
870
* properties as an array with raw values.
872
* @param object $object Object
873
* @param bool $recursive If true, inner entities will be cast as array as well
875
* @return array<string, mixed> Array with raw values.
877
* @throws ReflectionException
879
protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
881
return parent::objectToRawArray($object, $onlyChanged);
885
* Provides/instantiates the builder/db connection and model's table/primary key names and return type.
887
* @param string $name Name
889
* @return array|BaseBuilder|bool|float|int|object|string|null
891
public function __get(string $name)
893
if (parent::__isset($name)) {
894
return parent::__get($name);
897
return $this->builder()->{$name} ?? null;
901
* Checks for the existence of properties across this model, builder, and db connection.
903
* @param string $name Name
905
public function __isset(string $name): bool
907
if (parent::__isset($name)) {
911
return isset($this->builder()->{$name});
915
* Provides direct access to method in the builder (if available)
916
* and the database connection.
918
* @return $this|array|BaseBuilder|bool|float|int|object|string|null
920
public function __call(string $name, array $params)
922
$builder = $this->builder();
925
if (method_exists($this->db, $name)) {
926
$result = $this->db->{$name}(...$params);
927
} elseif (method_exists($builder, $name)) {
928
$this->checkBuilderMethod($name);
930
$result = $builder->{$name}(...$params);
932
throw new BadMethodCallException('Call to undefined method ' . static::class . '::' . $name);
935
if ($result instanceof BaseBuilder) {
943
* Checks the Builder method name that should not be used in the Model.
945
private function checkBuilderMethod(string $name): void
947
if (in_array($name, $this->builderMethodsNotAvailable, true)) {
948
throw ModelException::forMethodNotAvailable(static::class, $name . '()');