<?php /** * This file is part of the CodeIgniter 4 framework. * * (c) CodeIgniter Foundation <admin@codeigniter.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace CodeIgniter; use BadMethodCallException; use Closure; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Exceptions\ModelException; use CodeIgniter\I18n\Time; use CodeIgniter\Validation\ValidationInterface; use Config\Database; use ReflectionClass; use ReflectionException; use ReflectionProperty; /** * Class Model * * The Model class extends BaseModel and provides additional * convenient features that makes working with a SQL database * table less painful. * * It will: * - automatically connect to database * - allow intermingling calls to the builder * - removes the need to use Result object directly in most cases * * @property ConnectionInterface $db * * @mixin BaseBuilder */ class Model extends BaseModel { // region Properties /** * Name of database table * * @var string */ protected $table; /** * The table's primary key. * * @var string */ protected $primaryKey = 'id'; /** * Whether primary key uses auto increment. * * @var boolean */ protected $useAutoIncrement = true; /** * Query Builder object * * @var BaseBuilder|null */ protected $builder; /** * Holds information passed in via 'set' * so that we can capture it (not the builder) * and ensure it gets validated first. * * @var array */ protected $tempData = []; /** * Escape array that maps usage of escape * flag for every parameter. * * @var array */ protected $escape = []; // endregion // region Constructor /** * Model constructor. * * @param ConnectionInterface|null $db DB Connection * @param ValidationInterface|null $validation Validation */ public function __construct(ConnectionInterface &$db = null, ValidationInterface $validation = null) { parent::__construct($validation); if (is_null($db)) { $this->db = Database::connect($this->DBGroup); } else { $this->db = &$db; } } // endregion // region Setters /** * Specify the table associated with a model * * @param string $table Table * * @return $this */ public function setTable(string $table) { $this->table = $table; return $this; } // endregion // region Database Methods /** * Fetches the row of database from $this->table with a primary key * matching $id. This methods works only with dbCalls * This methods works only with dbCalls * * @param boolean $singleton Single or multiple results * @param array|integer|string|null $id One primary key or an array of primary keys * * @return array|object|null The resulting row of data, or null. */ protected function doFind(bool $singleton, $id = null) { $builder = $this->builder(); if ($this->tempUseSoftDeletes) { $builder->where($this->table . '.' . $this->deletedField, null); } if (is_array($id)) { $row = $builder->whereIn($this->table . '.' . $this->primaryKey, $id) ->get() ->getResult($this->tempReturnType); } elseif ($singleton) { $row = $builder->where($this->table . '.' . $this->primaryKey, $id) ->get() ->getFirstRow($this->tempReturnType); } else { $row = $builder->get()->getResult($this->tempReturnType); } return $row; } /** * Fetches the column of database from $this->table * This methods works only with dbCalls * * @param string $columnName Column Name * * @return array|null The resulting row of data, or null if no data found. */ protected function doFindColumn(string $columnName) { return $this->select($columnName)->asArray()->find(); } /** * Works with the current Query Builder instance to return * all results, while optionally limiting them. * This methods works only with dbCalls * * @param integer $limit Limit * @param integer $offset Offset * * @return array */ protected function doFindAll(int $limit = 0, int $offset = 0) { $builder = $this->builder(); if ($this->tempUseSoftDeletes) { $builder->where($this->table . '.' . $this->deletedField, null); } return $builder->limit($limit, $offset) ->get() ->getResult($this->tempReturnType); } /** * Returns the first row of the result set. Will take any previous * Query Builder calls into account when determining the result set. * This methods works only with dbCalls * * @return array|object|null */ protected function doFirst() { $builder = $this->builder(); if ($this->tempUseSoftDeletes) { $builder->where($this->table . '.' . $this->deletedField, null); } else { if ($this->useSoftDeletes && empty($builder->QBGroupBy) && $this->primaryKey) { $builder->groupBy($this->table . '.' . $this->primaryKey); } } // Some databases, like PostgreSQL, need order // information to consistently return correct results. if ($builder->QBGroupBy && empty($builder->QBOrderBy) && $this->primaryKey) { $builder->orderBy($this->table . '.' . $this->primaryKey, 'asc'); } return $builder->limit(1, 0)->get()->getFirstRow($this->tempReturnType); } /** * Inserts data into the current table. * This methods works only with dbCalls * * @param array $data Data * * @return BaseResult|integer|string|false */ protected function doInsert(array $data) { $escape = $this->escape; $this->escape = []; // Require non empty primaryKey when // not using auto-increment feature if (! $this->useAutoIncrement && empty($data[$this->primaryKey])) { throw DataException::forEmptyPrimaryKey('insert'); } $builder = $this->builder(); // Must use the set() method to ensure to set the correct escape flag foreach ($data as $key => $val) { $builder->set($key, $val, $escape[$key] ?? null); } $result = $builder->insert(); // If insertion succeeded then save the insert ID if ($result->resultID) { if (! $this->useAutoIncrement) { $this->insertID = $data[$this->primaryKey]; } else { // @phpstan-ignore-next-line $this->insertID = $this->db->insertID(); } } return $result; } /** * Compiles batch insert strings and runs the queries, validating each row prior. * This methods works only with dbCalls * * @param array|null $set An associative array of insert values * @param boolean|null $escape Whether to escape values and identifiers * @param integer $batchSize The size of the batch to run * @param boolean $testing True means only number of records is returned, false will execute the query * * @return integer|boolean Number of rows inserted or FALSE on failure */ protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false) { if (is_array($set)) { foreach ($set as $row) { // Require non empty primaryKey when // not using auto-increment feature if (! $this->useAutoIncrement && empty($row[$this->primaryKey])) { throw DataException::forEmptyPrimaryKey('insertBatch'); } } } return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize); } /** * Updates a single record in $this->table. * This methods works only with dbCalls * * @param integer|array|string|null $id ID * @param array|null $data Data * * @return boolean */ protected function doUpdate($id = null, $data = null): bool { $escape = $this->escape; $this->escape = []; $builder = $this->builder(); if ($id) { $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id); } // Must use the set() method to ensure to set the correct escape flag foreach ($data as $key => $val) { $builder->set($key, $val, $escape[$key] ?? null); } return $builder->update(); } /** * Compiles an update string and runs the query * This methods works only with dbCalls * * @param array|null $set An associative array of update values * @param string|null $index The where key * @param integer $batchSize The size of the batch to run * @param boolean $returnSQL True means SQL is returned, false will execute the query * * @return mixed Number of rows affected or FALSE on failure * * @throws DatabaseException */ protected function doUpdateBatch(array $set = null, string $index = null, int $batchSize = 100, bool $returnSQL = false) { return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize); } /** * Deletes a single record from $this->table where $id matches * the table's primaryKey * This methods works only with dbCalls * * @param integer|string|array|null $id The rows primary key(s) * @param boolean $purge Allows overriding the soft deletes setting. * * @return BaseResult|boolean * * @throws DatabaseException */ protected function doDelete($id = null, bool $purge = false) { $builder = $this->builder(); if ($id) { $builder = $builder->whereIn($this->primaryKey, $id); } if ($this->useSoftDeletes && ! $purge) { if (empty($builder->getCompiledQBWhere())) { if (CI_DEBUG) { throw new DatabaseException( 'Deletes are not allowed unless they contain a "where" or "like" clause.' ); } // @codeCoverageIgnoreStart return false; // @codeCoverageIgnoreEnd } $set[$this->deletedField] = $this->setDate(); if ($this->useTimestamps && $this->updatedField) { $set[$this->updatedField] = $this->setDate(); } $result = $builder->update($set); } else { $result = $builder->delete(); } return $result; } /** * Permanently deletes all rows that have been marked as deleted * through soft deletes (deleted = 1) * This methods works only with dbCalls * * @return boolean|mixed */ protected function doPurgeDeleted() { return $this->builder() ->where($this->table . '.' . $this->deletedField . ' IS NOT NULL') ->delete(); } /** * Works with the find* methods to return only the rows that * have been deleted. * This methods works only with dbCalls * * @return void */ protected function doOnlyDeleted() { $this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL'); } /** * Compiles a replace into string and runs the query * This methods works only with dbCalls * * @param array|null $data Data * @param boolean $returnSQL Set to true to return Query String * * @return mixed */ protected function doReplace(array $data = null, bool $returnSQL = false) { return $this->builder()->testMode($returnSQL)->replace($data); } /** * Grabs the last error(s) that occurred from the Database connection. * This methods works only with dbCalls * * @return array|null */ protected function doErrors() { return $this->db->error(); } /** * Returns the id value for the data array or object * * @param array|object $data Data * * @return integer|array|string|null */ protected function idValue($data) { if (is_object($data) && isset($data->{$this->primaryKey})) { return $data->{$this->primaryKey}; } if (is_array($data) && ! empty($data[$this->primaryKey])) { return $data[$this->primaryKey]; } return null; } /** * Loops over records in batches, allowing you to operate on them. * Works with $this->builder to get the Compiled select to * determine the rows to operate on. * This methods works only with dbCalls * * @param integer $size Size * @param Closure $userFunc Callback Function * * @return void * * @throws DataException */ public function chunk(int $size, Closure $userFunc) { $total = $this->builder()->countAllResults(false); $offset = 0; while ($offset <= $total) { $builder = clone $this->builder(); $rows = $builder->get($size, $offset); if (! $rows) { throw DataException::forEmptyDataset('chunk'); } $rows = $rows->getResult($this->tempReturnType); $offset += $size; if (empty($rows)) { continue; } foreach ($rows as $row) { if ($userFunc($row) === false) { return; } } } } /** * Override countAllResults to account for soft deleted accounts. * * @param boolean $reset Reset * @param boolean $test Test * * @return mixed */ public function countAllResults(bool $reset = true, bool $test = false) { if ($this->tempUseSoftDeletes) { $this->builder()->where($this->table . '.' . $this->deletedField, null); } // When $reset === false, the $tempUseSoftDeletes will be // dependant on $useSoftDeletes value because we don't // want to add the same "where" condition for the second time $this->tempUseSoftDeletes = $reset ? $this->useSoftDeletes : ($this->useSoftDeletes ? false : $this->useSoftDeletes); return $this->builder()->testMode($test)->countAllResults($reset); } // endregion // region Builder /** * Provides a shared instance of the Query Builder. * * @param string|null $table Table name * * @return BaseBuilder * @throws ModelException */ public function builder(?string $table = null) { // Check for an existing Builder if ($this->builder instanceof BaseBuilder) { // Make sure the requested table matches the builder if ($table && $this->builder->getTable() !== $table) { return $this->db->table($table); } return $this->builder; } // We're going to force a primary key to exist // so we don't have overly convoluted code, // and future features are likely to require them. if (empty($this->primaryKey)) { throw ModelException::forNoPrimaryKey(static::class); } $table = empty($table) ? $this->table : $table; // Ensure we have a good db connection if (! $this->db instanceof BaseConnection) { $this->db = Database::connect($this->DBGroup); } $builder = $this->db->table($table); // Only consider it "shared" if the table is correct if ($table === $this->table) { $this->builder = $builder; } return $builder; } /** * Captures the builder's set() method so that we can validate the * data here. This allows it to be used with any of the other * builder methods and still get validated data, like replace. * * @param mixed $key Field name, or an array of field/value pairs * @param string|null $value Field value, if $key is a single field * @param boolean|null $escape Whether to escape values and identifiers * * @return $this */ public function set($key, ?string $value = '', ?bool $escape = null) { $data = is_array($key) ? $key : [$key => $value]; foreach ($data as $k => $v) { $this->tempData['escape'][$k] = $escape; } $this->tempData['data'] = array_merge($this->tempData['data'] ?? [], $data); return $this; } // endregion // region Overrides // region CRUD & Finders /** * This method is called on save to determine if entry have to be updated * If this method return false insert operation will be executed * * @param array|object $data Data * * @return boolean */ protected function shouldUpdate($data) : bool { // When useAutoIncrement feature is disabled check // in the database if given record already exists return parent::shouldUpdate($data) && ($this->useAutoIncrement ? true : $this->where($this->primaryKey, $this->idValue($data))->countAllResults() === 1 ); } /** * Inserts data into the database. If an object is provided, * it will attempt to convert it to an array. * * @param array|object|null $data Data * @param boolean $returnID Whether insert ID should be returned or not. * * @return BaseResult|object|integer|string|false * * @throws ReflectionException */ public function insert($data = null, bool $returnID = true) { if (! empty($this->tempData['data'])) { if (empty($data)) { $data = $this->tempData['data'] ?? null; } else { $data = $this->transformDataToArray($data, 'insert'); $data = array_merge($this->tempData['data'], $data); } } $this->escape = $this->tempData['escape'] ?? []; $this->tempData = []; return parent::insert($data, $returnID); } /** * Updates a single record in the database. If an object is provided, * it will attempt to convert it into an array. * * @param integer|array|string|null $id ID * @param array|object|null $data Data * * @return boolean * * @throws ReflectionException */ public function update($id = null, $data = null): bool { if (! empty($this->tempData['data'])) { if (empty($data)) { $data = $this->tempData['data'] ?? null; } else { $data = $this->transformDataToArray($data, 'update'); $data = array_merge($this->tempData['data'], $data); } } $this->escape = $this->tempData['escape'] ?? []; $this->tempData = []; return parent::update($id, $data); } // endregion // region Utility /** * Takes a class an returns an array of it's public and protected * properties as an array with raw values. * * @param string|object $data Data * @param boolean $onlyChanged Only Changed Property * @param boolean $recursive If true, inner entities will be casted as array as well * * @return array|null Array * * @throws ReflectionException */ protected function objectToRawArray($data, bool $onlyChanged = true, bool $recursive = false): ?array { $properties = parent::objectToRawArray($data, $onlyChanged); if (method_exists($data, 'toRawArray')) { // Always grab the primary key otherwise updates will fail. if (! empty($properties) && ! empty($this->primaryKey) && ! in_array($this->primaryKey, $properties, true) && ! empty($data->{$this->primaryKey})) { $properties[$this->primaryKey] = $data->{$this->primaryKey}; } } return $properties; } // endregion // region Magic /** * Provides/instantiates the builder/db connection and model's table/primary key names and return type. * * @param string $name Name * * @return mixed */ public function __get(string $name) { if (parent::__isset($name)) { return parent::__get($name); } if (isset($this->builder()->$name)) { return $this->builder()->$name; } return null; } /** * Checks for the existence of properties across this model, builder, and db connection. * * @param string $name Name * * @return boolean */ public function __isset(string $name): bool { if (parent::__isset($name)) { return true; } if (isset($this->builder()->$name)) { return true; } return false; } /** * Provides direct access to method in the builder (if available) * and the database connection. * * @param string $name Name * @param array $params Params * * @return $this|null */ public function __call(string $name, array $params) { $result = parent::__call($name, $params); if ($result === null && method_exists($builder = $this->builder(), $name)) { $result = $builder->{$name}(...$params); } if (empty($result)) { if (! method_exists($this->builder(), $name)) { $className = static::class; throw new BadMethodCallException('Call to undefined method ' . $className . '::' . $name); } return $result; } if ($result instanceof BaseBuilder) { return $this; } return $result; } // endregion // endregion // region Deprecated /** * Takes a class an returns an array of it's public and protected * properties as an array suitable for use in creates and updates. * * @param string|object $data Data * @param string|null $primaryKey Primary Key * @param string $dateFormat Date Format * @param boolean $onlyChanged Only Changed * * @return array * * @throws ReflectionException * * @codeCoverageIgnore * * @deprecated since 4.1 */ public static function classToArray($data, $primaryKey = null, string $dateFormat = 'datetime', bool $onlyChanged = true): array { if (method_exists($data, 'toRawArray')) { $properties = $data->toRawArray($onlyChanged); // Always grab the primary key otherwise updates will fail. if (! empty($properties) && ! empty($primaryKey) && ! in_array($primaryKey, $properties, true) && ! empty($data->{$primaryKey})) { $properties[$primaryKey] = $data->{$primaryKey}; } } else { $mirror = new ReflectionClass($data); $props = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED); $properties = []; // Loop over each property, // saving the name/value in a new array we can return. foreach ($props as $prop) { // Must make protected values accessible. $prop->setAccessible(true); $properties[$prop->getName()] = $prop->getValue($data); } } // Convert any Time instances to appropriate $dateFormat if ($properties) { foreach ($properties as $key => $value) { if ($value instanceof Time) { switch ($dateFormat) { case 'datetime': $converted = $value->format('Y-m-d H:i:s'); break; case 'date': $converted = $value->format('Y-m-d'); break; case 'int': $converted = $value->getTimestamp(); break; default: $converted = (string) $value; } $properties[$key] = $converted; } } } return $properties; } // endregion }