<?php /** * CodeIgniter * * An open source application development framework for PHP * * This content is released under the MIT License (MIT) * * Copyright (c) 2014-2019 British Columbia Institute of Technology * Copyright (c) 2019-2020 CodeIgniter Foundation * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * @package CodeIgniter * @author CodeIgniter Dev Team * @copyright 2019-2020 CodeIgniter Foundation * @license https://opensource.org/licenses/MIT MIT License * @link https://codeigniter.com * @since Version 4.0.0 * @filesource */ namespace CodeIgniter; use CodeIgniter\Exceptions\EntityException; use CodeIgniter\I18n\Time; use CodeIgniter\Exceptions\CastException; /** * Entity encapsulation, for use with CodeIgniter\Model */ class Entity { /** * Maps names used in sets and gets against unique * names within the class, allowing independence from * database column names. * * Example: * $datamap = [ * 'db_name' => 'class_name' * ]; */ protected $datamap = []; protected $dates = [ 'created_at', 'updated_at', 'deleted_at', ]; /** * Array of field names and the type of value to cast them as * when they are accessed. */ protected $casts = []; /** * Holds the current values of all class vars. * * @var array */ protected $attributes = []; /** * Holds original copies of all class vars so * we can determine what's actually been changed * and not accidentally write nulls where we shouldn't. * * @var array */ protected $original = []; /** * Holds info whenever properties have to be casted * * @var boolean **/ private $_cast = true; /** * Allows filling in Entity parameters during construction. * * @param array|null $data */ public function __construct(array $data = null) { $this->syncOriginal(); $this->fill($data); } /** * Takes an array of key/value pairs and sets them as * class properties, using any `setCamelCasedProperty()` methods * that may or may not exist. * * @param array $data * * @return \CodeIgniter\Entity */ public function fill(array $data = null) { if (! is_array($data)) { return $this; } foreach ($data as $key => $value) { $key = $this->mapProperty($key); $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); if (method_exists($this, $method)) { $this->$method($value); } else { $this->attributes[$key] = $value; } } return $this; } //-------------------------------------------------------------------- /** * General method that will return all public and protected * values of this entity as an array. All values are accessed * through the __get() magic method so will have any casts, etc * applied to them. * * @param boolean $onlyChanged If true, only return values that have changed since object creation * @param boolean $cast If true, properties will be casted. * * @return array * @throws \Exception */ public function toArray(bool $onlyChanged = false, bool $cast = true): array { $this->_cast = $cast; $return = []; // we need to loop over our properties so that we // allow our magic methods a chance to do their thing. foreach ($this->attributes as $key => $value) { if (substr($key, 0, 1) === '_') { continue; } if ($onlyChanged && ! $this->hasChanged($key)) { continue; } $return[$key] = $this->__get($key); } // Loop over our mapped properties and add them to the list... if (is_array($this->datamap)) { foreach ($this->datamap as $from => $to) { if (array_key_exists($to, $return)) { $return[$from] = $this->__get($to); } } } $this->_cast = true; return $return; } //-------------------------------------------------------------------- /** * Returns the raw values of the current attributes. * * @param boolean $onlyChanged * * @return array */ public function toRawArray(bool $onlyChanged = false): array { $return = []; if (! $onlyChanged) { return $this->attributes; } foreach ($this->attributes as $key => $value) { if (! $this->hasChanged($key)) { continue; } $return[$key] = $this->attributes[$key]; } return $return; } //-------------------------------------------------------------------- /** * Ensures our "original" values match the current values. * * @return $this */ public function syncOriginal() { $this->original = $this->attributes; return $this; } /** * Checks a property to see if it has changed since the entity was created. * Or, without a parameter, checks if any properties have changed. * * @param string $key * * @return boolean */ public function hasChanged(string $key = null): bool { // If no parameter was given then check all attributes if ($key === null) { return $this->original !== $this->attributes; } // Key doesn't exist in either if (! array_key_exists($key, $this->original) && ! array_key_exists($key, $this->attributes)) { return false; } // It's a new element if (! array_key_exists($key, $this->original) && array_key_exists($key, $this->attributes)) { return true; } return $this->original[$key] !== $this->attributes[$key]; } /** * Magic method to allow retrieval of protected and private * class properties either by their name, or through a `getCamelCasedProperty()` * method. * * Examples: * * $p = $this->my_property * $p = $this->getMyProperty() * * @param string $key * * @return mixed * @throws \Exception */ public function __get(string $key) { $key = $this->mapProperty($key); $result = null; // Convert to CamelCase for the method $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); // if a set* method exists for this key, // use that method to insert this value. if (method_exists($this, $method)) { $result = $this->$method(); } // Otherwise return the protected property // if it exists. else if (array_key_exists($key, $this->attributes)) { $result = $this->attributes[$key]; } // Do we need to mutate this into a date? if (in_array($key, $this->dates)) { $result = $this->mutateDate($result); } // Or cast it as something? else if ($this->_cast && ! empty($this->casts[$key])) { $result = $this->castAs($result, $this->casts[$key]); } return $result; } //-------------------------------------------------------------------- /** * Magic method to all protected/private class properties to be easily set, * either through a direct access or a `setCamelCasedProperty()` method. * * Examples: * * $this->my_property = $p; * $this->setMyProperty() = $p; * * @param string $key * @param null $value * * @return $this * @throws \Exception */ public function __set(string $key, $value = null) { $key = $this->mapProperty($key); // Check if the field should be mutated into a date if (in_array($key, $this->dates)) { $value = $this->mutateDate($value); } $isNullable = false; $castTo = false; if (array_key_exists($key, $this->casts)) { $isNullable = substr($this->casts[$key], 0, 1) === '?'; $castTo = $isNullable ? substr($this->casts[$key], 1) : $this->casts[$key]; } if (! $isNullable || ! is_null($value)) { // Array casting requires that we serialize the value // when setting it so that it can easily be stored // back to the database. if ($castTo === 'array') { $value = serialize($value); } // JSON casting requires that we JSONize the value // when setting it so that it can easily be stored // back to the database. if (($castTo === 'json' || $castTo === 'json-array') && function_exists('json_encode')) { $value = json_encode($value); if (json_last_error() !== JSON_ERROR_NONE) { throw CastException::forInvalidJsonFormatException(json_last_error()); } } } // if a set* method exists for this key, // use that method to insert this value. // *) should be outside $isNullable check - SO maybe wants to do sth with null value automatically $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); if (method_exists($this, $method)) { $this->$method($value); return $this; } // Otherwise, just the value. // This allows for creation of new class // properties that are undefined, though // they cannot be saved. Useful for // grabbing values through joins, // assigning relationships, etc. $this->attributes[$key] = $value; return $this; } //-------------------------------------------------------------------- /** * Unsets an attribute property. * * @param string $key * * @throws \ReflectionException */ public function __unset(string $key) { unset($this->attributes[$key]); } //-------------------------------------------------------------------- /** * Returns true if a property exists names $key, or a getter method * exists named like for __get(). * * @param string $key * * @return boolean */ public function __isset(string $key): bool { $key = $this->mapProperty($key); $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); if (method_exists($this, $method)) { return true; } return isset($this->attributes[$key]); } /** * Set raw data array without any mutations * * @param array $data * @return $this */ public function setAttributes(array $data) { $this->attributes = $data; $this->syncOriginal(); return $this; } //-------------------------------------------------------------------- /** * Checks the datamap to see if this column name is being mapped, * and returns the mapped name, if any, or the original name. * * @param string $key * * @return mixed|string */ protected function mapProperty(string $key) { if (empty($this->datamap)) { return $key; } if (! empty($this->datamap[$key])) { return $this->datamap[$key]; } return $key; } //-------------------------------------------------------------------- /** * Converts the given string|timestamp|DateTime|Time instance * into a \CodeIgniter\I18n\Time object. * * @param $value * * @return \CodeIgniter\I18n\Time * @throws \Exception */ protected function mutateDate($value) { if ($value instanceof Time) { return $value; } if ($value instanceof \DateTime) { return Time::instance($value); } if (is_numeric($value)) { return Time::createFromTimestamp($value); } if (is_string($value)) { return Time::parse($value); } return $value; } //-------------------------------------------------------------------- /** * Provides the ability to cast an item as a specific data type. * Add ? at the beginning of $type (i.e. ?string) to get NULL instead of casting $value if $value === null * * @param $value * @param string $type * * @return mixed * @throws \Exception */ protected function castAs($value, string $type) { if (substr($type, 0, 1) === '?') { if ($value === null) { return null; } $type = substr($type, 1); } switch($type) { case 'int': case 'integer': //alias for 'integer' $value = (int)$value; break; case 'float': $value = (float)$value; break; case 'double': $value = (double)$value; break; case 'string': $value = (string)$value; break; case 'bool': case 'boolean': //alias for 'boolean' $value = (bool)$value; break; case 'object': $value = (object)$value; break; case 'array': if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) { $value = unserialize($value); } $value = (array)$value; break; case 'json': $value = $this->castAsJson($value, false); break; case 'json-array': $value = $this->castAsJson($value, true); break; case 'datetime': return new \DateTime($value); break; case 'timestamp': return strtotime($value); break; } return $value; } //-------------------------------------------------------------------- /** * Cast as JSON * * @param mixed $value * @param boolean $asArray * * @return mixed * @throws \CodeIgniter\Exceptions\CastException */ private function castAsJson($value, bool $asArray = false) { $tmp = ! is_null($value) ? ($asArray ? [] : new \stdClass) : null; if (function_exists('json_decode')) { if ((is_string($value) && strlen($value) > 1 && in_array($value[0], ['[', '{', '"'])) || is_numeric($value)) { $tmp = json_decode($value, $asArray); if (json_last_error() !== JSON_ERROR_NONE) { throw CastException::forInvalidJsonFormatException(json_last_error()); } } } return $tmp; } }