<?php
namespace CodeIgniter;
use Closure;
use CodeIgniter\Exceptions\ModelException;
use Config\Database;
use CodeIgniter\I18n\Time;
use CodeIgniter\Pager\Pager;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\ConnectionInterface;
use CodeIgniter\Validation\ValidationInterface;
use CodeIgniter\Database\Exceptions\DataException;
use CodeIgniter\Database\Exceptions\DatabaseException;
use ReflectionClass;
use ReflectionProperty;
use stdClass;
class Model
{
public $pager;
protected $table;
protected $primaryKey = 'id';
protected $insertID = 0;
protected $DBGroup;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [];
protected $useTimestamps = false;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $tempUseSoftDeletes;
protected $deletedField = 'deleted_at';
protected $tempReturnType;
protected $protectFields = true;
protected $db;
protected $builder;
protected $validationRules = [];
protected $validationMessages = [];
protected $skipValidation = false;
protected $validation;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
protected $tempData = [];
public function __construct(ConnectionInterface &$db = null, ValidationInterface $validation = null)
{
if ($db instanceof ConnectionInterface)
{
$this->db = & $db;
}
else
{
$this->db = Database::connect($this->DBGroup);
}
$this->tempReturnType = $this->returnType;
$this->tempUseSoftDeletes = $this->useSoftDeletes;
if (is_null($validation))
{
$validation = \Config\Services::validation(null, false);
}
$this->validation = $validation;
}
public function find($id = null)
{
$builder = $this->builder();
if ($this->tempUseSoftDeletes === true)
{
$builder->where($this->table . '.' . $this->deletedField, null);
}
if (is_array($id))
{
$row = $builder->whereIn($this->table . '.' . $this->primaryKey, $id)
->get();
$row = $row->getResult($this->tempReturnType);
}
elseif (is_numeric($id) || is_string($id))
{
$row = $builder->where($this->table . '.' . $this->primaryKey, $id)
->get();
$row = $row->getFirstRow($this->tempReturnType);
}
else
{
$row = $builder->get();
$row = $row->getResult($this->tempReturnType);
}
$row = $this->trigger('afterFind', ['id' => $id, 'data' => $row]);
$this->tempReturnType = $this->returnType;
$this->tempUseSoftDeletes = $this->useSoftDeletes;
return $row['data'];
}
public function findColumn(string $columnName)
{
if (strpos($columnName, ',') !== false)
{
throw DataException::forFindColumnHaveMultipleColumns();
}
$resultSet = $this->select($columnName)
->asArray()
->find();
return (! empty($resultSet)) ? array_column($resultSet, $columnName) : null;
}
public function findAll(int $limit = 0, int $offset = 0)
{
$builder = $this->builder();
if ($this->tempUseSoftDeletes === true)
{
$builder->where($this->table . '.' . $this->deletedField, null);
}
$row = $builder->limit($limit, $offset)
->get();
$row = $row->getResult($this->tempReturnType);
$row = $this->trigger('afterFind', ['data' => $row, 'limit' => $limit, 'offset' => $offset]);
$this->tempReturnType = $this->returnType;
$this->tempUseSoftDeletes = $this->useSoftDeletes;
return $row['data'];
}
public function first()
{
$builder = $this->builder();
if ($this->tempUseSoftDeletes === true)
{
$builder->where($this->table . '.' . $this->deletedField, null);
}
if (empty($builder->QBOrderBy) && ! empty($this->primaryKey))
{
$builder->orderBy($this->table . '.' . $this->primaryKey, 'asc');
}
$row = $builder->limit(1, 0)
->get();
$row = $row->getFirstRow($this->tempReturnType);
$row = $this->trigger('afterFind', ['data' => $row]);
$this->tempReturnType = $this->returnType;
return $row['data'];
}
public function set($key, string $value = '', bool $escape = null)
{
$data = is_array($key)
? $key
: [$key => $value];
$this->tempData['escape'] = $escape;
$this->tempData['data'] = array_merge($this->tempData['data'] ?? [], $data);
return $this;
}
public function save($data): bool
{
if (empty($data))
{
return true;
}
if (is_object($data) && isset($data->{$this->primaryKey}))
{
$response = $this->update($data->{$this->primaryKey}, $data);
}
elseif (is_array($data) && ! empty($data[$this->primaryKey]))
{
$response = $this->update($data[$this->primaryKey], $data);
}
else
{
$response = $this->insert($data, false);
if ($response !== false)
{
$response = true;
}
}
return $response;
}
public static function classToArray($data, $primaryKey = null, string $dateFormat = 'datetime', bool $onlyChanged = true): array
{
if (method_exists($data, 'toRawArray'))
{
$properties = $data->toRawArray($onlyChanged);
if (! empty($properties) && ! empty($primaryKey) && ! in_array($primaryKey, $properties))
{
$properties[$primaryKey] = $data->{$primaryKey};
}
}
else
{
$mirror = new ReflectionClass($data);
$props = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
$properties = [];
foreach ($props as $prop)
{
$prop->setAccessible(true);
$propName = $prop->getName();
$properties[$propName] = $prop->getValue($data);
}
}
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;
}
public function getInsertID(): int
{
return $this->insertID;
}
public function insert($data = null, bool $returnID = true)
{
$escape = null;
$this->insertID = 0;
if (empty($data))
{
$data = $this->tempData['data'] ?? null;
$escape = $this->tempData['escape'] ?? null;
$this->tempData = [];
}
if (empty($data))
{
throw DataException::forEmptyDataset('insert');
}
if (is_object($data) && ! $data instanceof stdClass)
{
$data = static::classToArray($data, $this->primaryKey, $this->dateFormat, false);
}
if (is_object($data))
{
$data = (array) $data;
}
if ($this->skipValidation === false)
{
if ($this->validate($data) === false)
{
return false;
}
}
$data = $this->doProtectFields($data);
$date = $this->setDate();
if ($this->useTimestamps && ! empty($this->createdField) && ! array_key_exists($this->createdField, $data))
{
$data[$this->createdField] = $date;
}
if ($this->useTimestamps && ! empty($this->updatedField) && ! array_key_exists($this->updatedField, $data))
{
$data[$this->updatedField] = $date;
}
$data = $this->trigger('beforeInsert', ['data' => $data]);
$result = $this->builder()
->set($data['data'], '', $escape)
->insert();
if ($result)
{
$this->insertID = $this->db->insertID();
}
$this->trigger('afterInsert', ['id' => $this->insertID, 'data' => $data, 'result' => $result]);
if (! $result)
{
return $result;
}
return $returnID ? $this->insertID : $result;
}
public function insertBatch(array $set = null, bool $escape = null, int $batchSize = 100, bool $testing = false)
{
if (is_array($set) && $this->skipValidation === false)
{
foreach ($set as $row)
{
if ($this->validate($row) === false)
{
return false;
}
}
}
return $this->builder()->insertBatch($set, $escape, $batchSize, $testing);
}
public function update($id = null, $data = null): bool
{
$escape = null;
if (is_numeric($id) || is_string($id))
{
$id = [$id];
}
if (empty($data))
{
$data = $this->tempData['data'] ?? null;
$escape = $this->tempData['escape'] ?? null;
$this->tempData = [];
}
if (empty($data))
{
throw DataException::forEmptyDataset('update');
}
if (is_object($data) && ! $data instanceof stdClass)
{
$data = static::classToArray($data, $this->primaryKey, $this->dateFormat);
}
if (is_object($data))
{
$data = (array) $data;
}
if ($this->skipValidation === false)
{
if ($this->validate($data) === false)
{
return false;
}
}
$data = $this->doProtectFields($data);
if ($this->useTimestamps && ! empty($this->updatedField) && ! array_key_exists($this->updatedField, $data))
{
$data[$this->updatedField] = $this->setDate();
}
$data = $this->trigger('beforeUpdate', ['id' => $id, 'data' => $data]);
$builder = $this->builder();
if ($id)
{
$builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id);
}
$result = $builder
->set($data['data'], '', $escape)
->update();
$this->trigger('afterUpdate', ['id' => $id, 'data' => $data, 'result' => $result]);
return $result;
}
public function updateBatch(array $set = null, string $index = null, int $batchSize = 100, bool $returnSQL = false)
{
if (is_array($set) && $this->skipValidation === false)
{
foreach ($set as $row)
{
if ($this->validate($row) === false)
{
return false;
}
}
}
return $this->builder()->updateBatch($set, $index, $batchSize, $returnSQL);
}
public function delete($id = null, bool $purge = false)
{
if (! empty($id) && is_numeric($id))
{
$id = [$id];
}
$builder = $this->builder();
if (! empty($id))
{
$builder = $builder->whereIn($this->primaryKey, $id);
}
$this->trigger('beforeDelete', ['id' => $id, 'purge' => $purge]);
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.');
}
return false;
}
$set[$this->deletedField] = $this->setDate();
if ($this->useTimestamps && ! empty($this->updatedField))
{
$set[$this->updatedField] = $this->setDate();
}
$result = $builder->update($set);
}
else
{
$result = $builder->delete();
}
$this->trigger('afterDelete', ['id' => $id, 'purge' => $purge, 'result' => $result, 'data' => null]);
return $result;
}
public function purgeDeleted()
{
if (! $this->useSoftDeletes)
{
return true;
}
return $this->builder()
->where($this->table . '.' . $this->deletedField . ' IS NOT NULL')
->delete();
}
public function withDeleted($val = true)
{
$this->tempUseSoftDeletes = ! $val;
return $this;
}
public function onlyDeleted()
{
$this->tempUseSoftDeletes = false;
$this->builder()
->where($this->table . '.' . $this->deletedField . ' IS NOT NULL');
return $this;
}
public function replace($data = null, bool $returnSQL = false)
{
if (! empty($data) && $this->skipValidation === false)
{
if ($this->validate($data) === false)
{
return false;
}
}
return $this->builder()->replace($data, $returnSQL);
}
public function asArray()
{
$this->tempReturnType = 'array';
return $this;
}
public function asObject(string $class = 'object')
{
$this->tempReturnType = $class;
return $this;
}
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 === false)
{
throw DataException::forEmptyDataset('chunk');
}
$rows = $rows->getResult($this->tempReturnType);
$offset += $size;
if (empty($rows))
{
continue;
}
foreach ($rows as $row)
{
if ($userFunc($row) === false)
{
return;
}
}
}
}
public function paginate(int $perPage = 20, string $group = 'default', int $page = 0)
{
$page = $page >= 1 ? $page : (ctype_digit($_GET['page'] ?? '') && $_GET['page'] > 1 ? $_GET['page'] : 1);
$total = $this->countAllResults(false);
$pager = \Config\Services::pager();
$this->pager = $pager->store($group, $page, $perPage, $total);
$offset = ($page - 1) * $perPage;
return $this->findAll($perPage, $offset);
}
public function protect(bool $protect = true)
{
$this->protectFields = $protect;
return $this;
}
protected function builder(string $table = null)
{
if ($this->builder instanceof BaseBuilder)
{
return $this->builder;
}
if (empty($this->primaryKey))
{
throw ModelException::forNoPrimaryKey(get_class($this));
}
$table = empty($table) ? $this->table : $table;
if (! $this->db instanceof BaseConnection)
{
$this->db = Database::connect($this->DBGroup);
}
$this->builder = $this->db->table($table);
return $this->builder;
}
protected function doProtectFields(array $data): array
{
if ($this->protectFields === false)
{
return $data;
}
if (empty($this->allowedFields))
{
throw DataException::forInvalidAllowedFields(get_class($this));
}
if (is_array($data) && count($data))
{
foreach ($data as $key => $val)
{
if (! in_array($key, $this->allowedFields))
{
unset($data[$key]);
}
}
}
return $data;
}
protected function setDate(int $userData = null)
{
$currentDate = is_numeric($userData) ? (int) $userData : time();
switch ($this->dateFormat)
{
case 'int':
return $currentDate;
break;
case 'datetime':
return date('Y-m-d H:i:s', $currentDate);
break;
case 'date':
return date('Y-m-d', $currentDate);
break;
default:
throw ModelException::forNoDateFormat(get_class($this));
}
}
public function setTable(string $table)
{
$this->table = $table;
return $this;
}
public function errors(bool $forceDB = false)
{
if ($forceDB === false && $this->skipValidation === false)
{
$errors = $this->validation->getErrors();
if (! empty($errors))
{
return $errors;
}
}
$error = $this->db->getError();
return $error['message'] ?? null;
}
public function skipValidation(bool $skip = true)
{
$this->skipValidation = $skip;
return $this;
}
public function setValidationMessages(array $validationMessages)
{
$this->validationMessages = $validationMessages;
}
public function setValidationMessage(string $field, array $fieldMessages)
{
$this->validationMessages[$field] = $fieldMessages;
}
public function validate($data): bool
{
if ($this->skipValidation === true || empty($this->validationRules) || empty($data))
{
return true;
}
if (is_object($data))
{
$data = (array) $data;
}
$rules = $this->validationRules;
if (is_string($rules))
{
$rules = $this->validation->loadRuleGroup($rules);
}
$rules = $this->cleanValidationRules($rules, $data);
if (empty($rules))
{
return true;
}
$rules = $this->fillPlaceholders($rules, $data);
$this->validation->setRules($rules, $this->validationMessages);
$valid = $this->validation->run($data, null, $this->DBGroup);
return (bool) $valid;
}
protected function cleanValidationRules(array $rules, array $data = null): array
{
if (empty($data))
{
return [];
}
foreach ($rules as $field => $rule)
{
if (! array_key_exists($field, $data))
{
unset($rules[$field]);
}
}
return $rules;
}
protected function fillPlaceholders(array $rules, array $data): array
{
$replacements = [];
foreach ($data as $key => $value)
{
$replacements["{{$key}}"] = $value;
}
if (! empty($replacements))
{
foreach ($rules as &$rule)
{
if (is_array($rule))
{
foreach ($rule as &$row)
{
if (is_array($row))
{
continue;
}
$row = strtr($row, $replacements);
}
continue;
}
$rule = strtr($rule, $replacements);
}
}
return $rules;
}
public function getValidationRules(array $options = []): array
{
$rules = $this->validationRules;
if (isset($options['except']))
{
$rules = array_diff_key($rules, array_flip($options['except']));
}
elseif (isset($options['only']))
{
$rules = array_intersect_key($rules, array_flip($options['only']));
}
return $rules;
}
public function getValidationMessages(): array
{
return $this->validationMessages;
}
public function countAllResults(bool $reset = true, bool $test = false)
{
if ($this->tempUseSoftDeletes === true)
{
$this->builder()->where($this->table . '.' . $this->deletedField, null);
}
return $this->builder()->countAllResults($reset, $test);
}
protected function trigger(string $event, array $data)
{
if (! isset($this->{$event}) || empty($this->{$event}))
{
return $data;
}
foreach ($this->{$event} as $callback)
{
if (! method_exists($this, $callback))
{
throw DataException::forInvalidMethodTriggered($callback);
}
$data = $this->{$callback}($data);
}
return $data;
}
public function __get(string $name)
{
if (property_exists($this, $name))
{
return $this->{$name};
}
elseif (isset($this->db->$name))
{
return $this->db->$name;
}
elseif (isset($this->builder()->$name))
{
return $this->builder()->$name;
}
return null;
}
public function __isset(string $name): bool
{
if (property_exists($this, $name))
{
return true;
}
elseif (isset($this->db->$name))
{
return true;
}
elseif (isset($this->builder()->$name))
{
return true;
}
return false;
}
public function __call(string $name, array $params)
{
$result = null;
if (method_exists($this->db, $name))
{
$result = $this->db->$name(...$params);
}
elseif (method_exists($builder = $this->builder(), $name))
{
$result = $builder->$name(...$params);
}
if ($name !== 'builder' && empty($result))
{
return $result;
}
if ($name !== 'builder' && ! $result instanceof BaseBuilder)
{
return $result;
}
return $this;
}
}