diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 3793c6407323..759a007a651d 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -2056,26 +2056,6 @@ 'count' => 2, 'path' => __DIR__ . '/system/Encryption/Handlers/SodiumHandler.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$value \\(bool\\|int\\|string\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\IntBoolCast\\:\\:set\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\BaseCast\\:\\:set\\(\\)$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Entity/Cast/IntBoolCast.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$value \\(bool\\|int\\|string\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\IntBoolCast\\:\\:set\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\CastInterface\\:\\:set\\(\\)$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Entity/Cast/IntBoolCast.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$value \\(int\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\IntBoolCast\\:\\:get\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\BaseCast\\:\\:get\\(\\)$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Entity/Cast/IntBoolCast.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$value \\(int\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\IntBoolCast\\:\\:get\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\CastInterface\\:\\:get\\(\\)$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Entity/Cast/IntBoolCast.php', -]; $ignoreErrors[] = [ 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', 'count' => 4, diff --git a/rector.php b/rector.php index c2b76343dfff..62b458411884 100644 --- a/rector.php +++ b/rector.php @@ -77,6 +77,12 @@ __DIR__ . '/tests/system/Config/fixtures', __DIR__ . '/tests/system/Filters/fixtures', __DIR__ . '/tests/_support', + + // Error: ] Could not process "tests/system/Entity/EntityLiveTest.php" file, due + // to: + // "Call to a member function getValue() on bool". On line: 172 + __DIR__ . '/tests/system/Entity/EntityLiveTest.php', + JsonThrowOnErrorRector::class, YieldDataProviderRector::class, diff --git a/system/BaseModel.php b/system/BaseModel.php index 63a87e587828..588ae1951786 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -17,6 +17,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Database\Query; +use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\ModelException; use CodeIgniter\I18n\Time; use CodeIgniter\Pager\Pager; @@ -1684,8 +1685,11 @@ protected function objectToArray($data, bool $onlyChanged = true, bool $recursiv */ protected function objectToRawArray($data, bool $onlyChanged = true, bool $recursive = false): array { + if ($data instanceof Entity) { + $properties = $data->toDatabase($onlyChanged, $recursive); + } // @TODO Should define Interface or Class. Entity has toRawArray() now. - if (method_exists($data, 'toRawArray')) { + elseif (method_exists($data, 'toRawArray')) { $properties = $data->toRawArray($onlyChanged, $recursive); } else { $mirror = new ReflectionClass($data); @@ -1706,7 +1710,7 @@ protected function objectToRawArray($data, bool $onlyChanged = true, bool $recur } /** - * Transform data to array. + * Transform data to array that can be save to database. * * @param array|object|null $data Data * @param string $type Type of data (insert|update) diff --git a/system/Database/MySQLi/Result.php b/system/Database/MySQLi/Result.php index 578169deb757..049ab5c699e0 100644 --- a/system/Database/MySQLi/Result.php +++ b/system/Database/MySQLi/Result.php @@ -148,7 +148,7 @@ protected function fetchAssoc() protected function fetchObject(string $className = 'stdClass') { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + return empty($data = $this->fetchAssoc()) ? false : (new $className())->fromDatabase($data); } return $this->resultID->fetch_object($className); diff --git a/system/Database/OCI8/Result.php b/system/Database/OCI8/Result.php index 01025763d423..af9c103924e5 100644 --- a/system/Database/OCI8/Result.php +++ b/system/Database/OCI8/Result.php @@ -103,7 +103,7 @@ protected function fetchObject(string $className = 'stdClass') return $row; } if (is_subclass_of($className, Entity::class)) { - return (new $className())->injectRawData((array) $row); + return (new $className())->fromDatabase((array) $row); } $instance = new $className(); diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php index 0a828757632e..5109f77e747e 100644 --- a/system/Database/Postgre/Result.php +++ b/system/Database/Postgre/Result.php @@ -114,7 +114,7 @@ protected function fetchAssoc() protected function fetchObject(string $className = 'stdClass') { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + return empty($data = $this->fetchAssoc()) ? false : (new $className())->fromDatabase($data); } return pg_fetch_object($this->resultID, null, $className); diff --git a/system/Database/SQLSRV/Result.php b/system/Database/SQLSRV/Result.php index f245a01669c6..9f43025c9217 100755 --- a/system/Database/SQLSRV/Result.php +++ b/system/Database/SQLSRV/Result.php @@ -154,7 +154,7 @@ protected function fetchAssoc() protected function fetchObject(string $className = 'stdClass') { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + return empty($data = $this->fetchAssoc()) ? false : (new $className())->fromDatabase($data); } return sqlsrv_fetch_object($this->resultID, $className); diff --git a/system/Database/SQLite3/Result.php b/system/Database/SQLite3/Result.php index f3887b044b49..10cfb247673e 100644 --- a/system/Database/SQLite3/Result.php +++ b/system/Database/SQLite3/Result.php @@ -142,7 +142,7 @@ protected function fetchObject(string $className = 'stdClass') $classObj = new $className(); if (is_subclass_of($className, Entity::class)) { - return $classObj->injectRawData($row); + return $classObj->fromDatabase($row); } $classSet = Closure::bind(function ($key, $value) { diff --git a/system/Entity/Cast/ArrayCast.php b/system/Entity/Cast/ArrayCast.php index 315f2e582fec..e2e00a28bff6 100644 --- a/system/Entity/Cast/ArrayCast.php +++ b/system/Entity/Cast/ArrayCast.php @@ -19,10 +19,14 @@ class ArrayCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): array + public static function fromDatabase($value, array $params = []): array { - if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) { - $value = unserialize($value); + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + + if ((strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) { + $value = unserialize($value, ['allowed_classes' => false]); } return (array) $value; @@ -31,8 +35,12 @@ public static function get($value, array $params = []): array /** * {@inheritDoc} */ - public static function set($value, array $params = []): string + public static function toDatabase($value, array $params = []): string { + if (! is_array($value) && ! is_string($value)) { + self::invalidTypeValueError($value); + } + return serialize($value); } } diff --git a/system/Entity/Cast/BaseCast.php b/system/Entity/Cast/BaseCast.php index bfb156da3e53..96fa5f61bc4f 100644 --- a/system/Entity/Cast/BaseCast.php +++ b/system/Entity/Cast/BaseCast.php @@ -11,13 +11,16 @@ namespace CodeIgniter\Entity\Cast; +use TypeError; + /** * Class BaseCast */ abstract class BaseCast implements CastInterface { /** - * Get + * Returns value when getting the Entity property. + * This method is normally returns the value as it is. * * @param array|bool|float|int|object|string|null $value Data * @param array $params Additional param @@ -30,7 +33,7 @@ public static function get($value, array $params = []) } /** - * Set + * Returns value for Entity property when setting the Entity property. * * @param array|bool|float|int|object|string|null $value Data * @param array $params Additional param @@ -41,4 +44,42 @@ public static function set($value, array $params = []) { return $value; } + + /** + * Takes the Entity property value, returns its value for database. + * + * @param array|bool|float|int|object|string|null $value Data + * @param array $params Additional param + * + * @return bool|float|int|string|null + */ + public static function toDatabase($value, array $params = []) + { + return $value; + } + + /** + * Takes value from database, returns its value for the Entity property. + * + * @param bool|float|int|string|null $value Data + * @param array $params Additional param + * + * @return array|bool|float|int|object|string|null + */ + public static function fromDatabase($value, array $params = []) + { + return $value; + } + + /** + * Throws TypeError + * + * @param mixed $value + * + * @return never + */ + protected static function invalidTypeValueError($value) + { + throw new TypeError('Invalid type value: ' . var_export($value, true)); + } } diff --git a/system/Entity/Cast/BooleanCast.php b/system/Entity/Cast/BooleanCast.php index f46f5f5326bb..b3833177a0b1 100644 --- a/system/Entity/Cast/BooleanCast.php +++ b/system/Entity/Cast/BooleanCast.php @@ -19,8 +19,20 @@ class BooleanCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): bool + public static function set($value, array $params = []): bool { return (bool) $value; } + + /** + * {@inheritDoc} + */ + public static function fromDatabase($value, array $params = []): bool + { + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + + return (bool) $value; + } } diff --git a/system/Entity/Cast/CSVCast.php b/system/Entity/Cast/CSVCast.php index ba2414273543..f4cc2c9df632 100644 --- a/system/Entity/Cast/CSVCast.php +++ b/system/Entity/Cast/CSVCast.php @@ -19,16 +19,24 @@ class CSVCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): array + public static function fromDatabase($value, array $params = []): array { + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + return explode(',', $value); } /** * {@inheritDoc} */ - public static function set($value, array $params = []): string + public static function toDatabase($value, array $params = []): string { + if (! is_array($value)) { + self::invalidTypeValueError($value); + } + return implode(',', $value); } } diff --git a/system/Entity/Cast/CastInterface.php b/system/Entity/Cast/CastInterface.php index c1dcf1c40ead..fde3c8b03413 100644 --- a/system/Entity/Cast/CastInterface.php +++ b/system/Entity/Cast/CastInterface.php @@ -13,11 +13,15 @@ /** * Interface CastInterface + * + * [App Code] --- set() --> [Entity] --- toDatabase() ---> [Database] + * [App Code] <-- get() --- [Entity] <-- fromDatabase() -- [Database] */ interface CastInterface { /** - * Get + * Returns value when getting the Entity property. + * This method is normally returns the value as it is. * * @param array|bool|float|int|object|string|null $value Data * @param array $params Additional param @@ -27,7 +31,7 @@ interface CastInterface public static function get($value, array $params = []); /** - * Set + * Returns value for the Entity property when setting the Entity property. * * @param array|bool|float|int|object|string|null $value Data * @param array $params Additional param @@ -35,4 +39,24 @@ public static function get($value, array $params = []); * @return array|bool|float|int|object|string|null */ public static function set($value, array $params = []); + + /** + * Takes the Entity property value, returns its value for database. + * + * @param array|bool|float|int|object|string|null $value Data + * @param array $params Additional param + * + * @return bool|float|int|string|null + */ + public static function toDatabase($value, array $params = []); + + /** + * Takes value from database, returns its value for the Entity property. + * + * @param bool|float|int|string|null $value Data + * @param array $params Additional param + * + * @return array|bool|float|int|object|string|null + */ + public static function fromDatabase($value, array $params = []); } diff --git a/system/Entity/Cast/DatetimeCast.php b/system/Entity/Cast/DatetimeCast.php index 423300cdae6f..af4691c9a3b4 100644 --- a/system/Entity/Cast/DatetimeCast.php +++ b/system/Entity/Cast/DatetimeCast.php @@ -27,7 +27,7 @@ class DatetimeCast extends BaseCast * * @throws Exception */ - public static function get($value, array $params = []) + public static function set($value, array $params = []) { if ($value instanceof Time) { return $value; @@ -45,6 +45,34 @@ public static function get($value, array $params = []) return Time::parse($value); } - return $value; + self::invalidTypeValueError($value); + } + + /** + * {@inheritDoc} + * + * @return Time + * + * @throws Exception + */ + public static function fromDatabase($value, array $params = []) + { + if (is_string($value)) { + return Time::parse($value); + } + + self::invalidTypeValueError($value); + } + + /** + * {@inheritDoc} + */ + public static function toDatabase($value, array $params = []): string + { + if (! $value instanceof Time) { + self::invalidTypeValueError($value); + } + + return (string) $value; } } diff --git a/system/Entity/Cast/FloatCast.php b/system/Entity/Cast/FloatCast.php index 78f87570f32c..57e22dac58a3 100644 --- a/system/Entity/Cast/FloatCast.php +++ b/system/Entity/Cast/FloatCast.php @@ -19,7 +19,15 @@ class FloatCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): float + public static function set($value, array $params = []): float + { + return (float) $value; + } + + /** + * {@inheritDoc} + */ + public static function fromDatabase($value, array $params = []): float { return (float) $value; } diff --git a/system/Entity/Cast/IntBoolCast.php b/system/Entity/Cast/IntBoolCast.php index ce572d9eba4e..8533b8b1e6f6 100644 --- a/system/Entity/Cast/IntBoolCast.php +++ b/system/Entity/Cast/IntBoolCast.php @@ -19,18 +19,38 @@ final class IntBoolCast extends BaseCast { /** - * @param int $value + * {@inheritDoc} */ - public static function get($value, array $params = []): bool + public static function set($value, array $params = []): bool { + if (! is_bool($value) && ! is_int($value) && ! is_string($value)) { + self::invalidTypeValueError($value); + } + return (bool) $value; } /** - * @param bool|int|string $value + * {@inheritDoc} */ - public static function set($value, array $params = []): int + public static function fromDatabase($value, array $params = []): bool { + if (! is_int($value) && ! is_string($value)) { + self::invalidTypeValueError($value); + } + + return (bool) $value; + } + + /** + * {@inheritDoc} + */ + public static function toDatabase($value, array $params = []): int + { + if (! is_bool($value)) { + self::invalidTypeValueError($value); + } + return (int) $value; } } diff --git a/system/Entity/Cast/IntegerCast.php b/system/Entity/Cast/IntegerCast.php index b9357d7ed49b..b7ce069fa3c6 100644 --- a/system/Entity/Cast/IntegerCast.php +++ b/system/Entity/Cast/IntegerCast.php @@ -19,8 +19,20 @@ class IntegerCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): int + public static function set($value, array $params = []): int { return (int) $value; } + + /** + * {@inheritDoc} + */ + public static function fromDatabase($value, array $params = []): int + { + if (! is_string($value) && ! is_int($value)) { + self::invalidTypeValueError($value); + } + + return (int) $value; + } } diff --git a/system/Entity/Cast/JsonCast.php b/system/Entity/Cast/JsonCast.php index 534631be6167..a23ad69e2131 100644 --- a/system/Entity/Cast/JsonCast.php +++ b/system/Entity/Cast/JsonCast.php @@ -23,16 +23,20 @@ class JsonCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []) + public static function fromDatabase($value, array $params = []) { + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + $associative = in_array('array', $params, true); - $tmp = $value !== null ? ($associative ? [] : new stdClass()) : null; + // @TODO Can $value be null? + $tmp = ($associative ? [] : new stdClass()); if (function_exists('json_decode') && ( - (is_string($value) - && strlen($value) > 1 + (strlen($value) > 1 && in_array($value[0], ['[', '{', '"'], true)) || is_numeric($value) ) @@ -49,8 +53,10 @@ public static function get($value, array $params = []) /** * {@inheritDoc} + * + * @param mixed $value */ - public static function set($value, array $params = []): string + public static function toDatabase($value, array $params = []): string { if (function_exists('json_encode')) { try { diff --git a/system/Entity/Cast/ObjectCast.php b/system/Entity/Cast/ObjectCast.php index 0a22ed8a4a36..d533601ee216 100644 --- a/system/Entity/Cast/ObjectCast.php +++ b/system/Entity/Cast/ObjectCast.php @@ -11,6 +11,8 @@ namespace CodeIgniter\Entity\Cast; +use stdClass; + /** * Class ObjectCast */ @@ -19,8 +21,42 @@ class ObjectCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): object + public static function set($value, array $params = []): object { + if (! is_array($value)) { + self::invalidTypeValueError($value); + } + return (object) $value; } + + /** + * {@inheritDoc} + */ + public static function toDatabase($value, array $params = []) + { + if (! $value instanceof stdClass) { + self::invalidTypeValueError($value); + } + + // @TODO How to implement? + return serialize((array) $value); + } + + /** + * {@inheritDoc} + */ + public static function fromDatabase($value, array $params = []): object + { + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + + // @TODO How to implement? + if ((strpos($value, 'a:') === 0)) { + return (object) unserialize($value, ['allowed_classes' => false]); + } + + self::invalidTypeValueError($value); + } } diff --git a/system/Entity/Cast/StringCast.php b/system/Entity/Cast/StringCast.php index 974567ccb62a..c8939bda2c19 100644 --- a/system/Entity/Cast/StringCast.php +++ b/system/Entity/Cast/StringCast.php @@ -19,7 +19,7 @@ class StringCast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): string + public static function set($value, array $params = []): string { return (string) $value; } diff --git a/system/Entity/Cast/TimestampCast.php b/system/Entity/Cast/TimestampCast.php index f9669d592eb7..538c7b39125c 100644 --- a/system/Entity/Cast/TimestampCast.php +++ b/system/Entity/Cast/TimestampCast.php @@ -20,9 +20,15 @@ class TimestampCast extends BaseCast { /** * {@inheritDoc} + * + * @return int */ - public static function get($value, array $params = []) + public static function set($value, array $params = []) { + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + $value = strtotime($value); if ($value === false) { diff --git a/system/Entity/Cast/URICast.php b/system/Entity/Cast/URICast.php index bb49e7454ddf..36f0b59bd893 100644 --- a/system/Entity/Cast/URICast.php +++ b/system/Entity/Cast/URICast.php @@ -21,8 +21,16 @@ class URICast extends BaseCast /** * {@inheritDoc} */ - public static function get($value, array $params = []): URI + public static function set($value, array $params = []): URI { - return $value instanceof URI ? $value : new URI($value); + if ($value instanceof URI) { + return $value; + } + + if (! is_string($value)) { + self::invalidTypeValueError($value); + } + + return new URI($value); } } diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 65898c012dc9..460717eeb6ca 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -45,7 +45,7 @@ class Entity implements JsonSerializable * * Example: * $datamap = [ - * 'class_property_name' => 'db_column_name' + * class_property_alias => db_column_name * ]; * * @var array @@ -103,23 +103,26 @@ class Entity implements JsonSerializable ]; /** - * Holds the current values of all class vars. + * Holds the current values of all class properties. + * The values are PHP representation, not the raw values from database. * - * @var array + * @var array */ - protected $attributes = []; + protected $attributes = [ + // db_column_name => PHP_value + ]; /** - * Holds original copies of all class vars so we can determine + * Holds original copies of all attributes, so we can determine * what's actually been changed and not accidentally write * nulls where we shouldn't. * - * @var array + * @var array */ protected $original = []; /** - * Holds info whenever properties have to be casted + * Holds info whenever properties have to be cast. */ private bool $_cast = true; @@ -158,7 +161,7 @@ public function fill(?array $data = null) /** * 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. + * __get() magic method so will have any casts, etc. applied to them. * * @param bool $onlyChanged If true, only return values that have changed since object creation * @param bool $cast If true, properties will be cast. @@ -201,29 +204,54 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu } /** - * Returns the raw values of the current attributes. + * Returns the values for database of the current attributes. * * @param bool $onlyChanged If true, only return values that have changed since object creation * @param bool $recursive If true, inner entities will be cast as array as well. + * + * @deprecated 4.5.0 Use toDatabase() instead. */ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array + { + return $this->toDatabase($onlyChanged, $recursive); + } + + /** + * Returns the values for database of the current attributes. + * + * @param bool $onlyChanged If true, only return values that have changed since object creation + * @param bool $recursive If true, inner entities will be cast as array as well. + */ + public function toDatabase(bool $onlyChanged = false, bool $recursive = false): array { $return = []; if (! $onlyChanged) { + $data = $this->attributes; + + // Cast values + if ($this->_cast) { + foreach (array_keys($this->casts) as $field) { + if (isset($data[$field])) { + $data[$field] = $this->castAs($data[$field], $field, 'toDatabase'); + } + } + } + if ($recursive) { return array_map(static function ($value) use ($onlyChanged, $recursive) { if ($value instanceof self) { - $value = $value->toRawArray($onlyChanged, $recursive); - } elseif (is_callable([$value, 'toRawArray'])) { - $value = $value->toRawArray(); + $value = $value->toDatabase($onlyChanged, $recursive); + } elseif (is_callable([$value, 'toDatabase'])) { + // @TODO Should define Interface or Class. + $value = $value->toDatabase(); } return $value; - }, $this->attributes); + }, $data); } - return $this->attributes; + return $data; } foreach ($this->attributes as $key => $value) { @@ -231,11 +259,17 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): continue; } + // Cast values + if ($this->_cast && isset($this->casts[$key])) { + $value = $this->castAs($value, $key, 'toDatabase'); + } + if ($recursive) { if ($value instanceof self) { - $value = $value->toRawArray($onlyChanged, $recursive); - } elseif (is_callable([$value, 'toRawArray'])) { - $value = $value->toRawArray(); + $value = $value->toDatabase($onlyChanged, $recursive); + } elseif (is_callable([$value, 'toDatabase'])) { + // @TODO Should define Interface or Class. + $value = $value->toDatabase(); } } @@ -290,6 +324,8 @@ public function hasChanged(?string $key = null): bool * Set raw data array without any mutations * * @return $this + * + * @deprecated 4.5.0 No longer used. `fromDatabase()` is used. */ public function injectRawData(array $data) { @@ -300,6 +336,35 @@ public function injectRawData(array $data) return $this; } + /** + * Set data from database + * + * @return $this + */ + public function fromDatabase(array $data) + { + // Mutate dates + foreach ($this->dates as $field) { + if (isset($data[$field])) { + $data[$field] = $this->mutateDate($data[$field]); + } + } + + // Cast values + if ($this->_cast) { + foreach (array_keys($this->casts) as $field) { + if (isset($data[$field])) { + $data[$field] = $this->castAs($data[$field], $field, 'fromDatabase'); + } + } + } + + $this->attributes = $data; + $this->syncOriginal(); + + return $this; + } + /** * Set raw data array without any mutations * @@ -335,15 +400,19 @@ protected function mapProperty(string $key) * Converts the given string|timestamp|DateTime|Time instance * into the "CodeIgniter\I18n\Time" object. * - * @param DateTime|float|int|string|Time $value + * @param DateTime|float|int|string|Time|null $value * - * @return Time + * @return Time|null * * @throws Exception */ protected function mutateDate($value) { - return DatetimeCast::get($value); + if ($value === null) { + return null; + } + + return DatetimeCast::set($value); } /** @@ -353,7 +422,8 @@ protected function mutateDate($value) * * @param bool|float|int|string|null $value Attribute value * @param string $attribute Attribute name - * @param string $method Allowed to "get" and "set" + * @param string $method Method name to run in the cast handler + * @phpstan-param 'get'|'set'|'toDatabase'|'fromDatabase' $method * * @return array|bool|float|int|object|string|null * @@ -383,7 +453,8 @@ protected function castAs($value, string $attribute, string $method = 'get') // json-array type, we transform the required one. $type = $type === 'json-array' ? 'json[array]' : $type; - if (! in_array($method, ['get', 'set'], true)) { + if (! in_array($method, ['get', 'set', 'toDatabase', 'fromDatabase'], true)) { + /** @psalm-suppress NoValue */ throw CastException::forInvalidMethod($method); } @@ -466,7 +537,9 @@ public function __set(string $key, $value = null) $value = $this->mutateDate($value); } - $value = $this->castAs($value, $dbColumn, 'set'); + if ($this->_cast) { + $value = $this->castAs($value, $dbColumn, 'set'); + } // if a setter method exists for this key, use that method to // insert this value. should be outside $isNullable check, diff --git a/tests/_support/Entity/Database/Migrations/2023-09-29-103000_CreateUsersTable.php b/tests/_support/Entity/Database/Migrations/2023-09-29-103000_CreateUsersTable.php new file mode 100644 index 000000000000..a0c527519680 --- /dev/null +++ b/tests/_support/Entity/Database/Migrations/2023-09-29-103000_CreateUsersTable.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Entity\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class CreateUsersTable extends Migration +{ + public function up(): void + { + $this->forge->addField([ + 'id' => ['type' => 'int', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'username' => ['type' => 'varchar', 'constraint' => 30, 'null' => true], + 'active' => ['type' => 'tinyint', 'constraint' => 1, 'null' => 0, 'default' => 0], + 'memo' => ['type' => 'text', 'null' => true], + 'created_at' => ['type' => 'datetime', 'null' => true], + 'updated_at' => ['type' => 'datetime', 'null' => true], + 'deleted_at' => ['type' => 'datetime', 'null' => true], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addUniqueKey('username'); + $this->forge->createTable('users'); + } + + public function down(): void + { + $this->forge->dropTable('users'); + } +} diff --git a/tests/system/Entity/EntityLiveTest.php b/tests/system/Entity/EntityLiveTest.php new file mode 100644 index 000000000000..3e76f226238e --- /dev/null +++ b/tests/system/Entity/EntityLiveTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity; + +use CodeIgniter\I18n\Time; +use CodeIgniter\Model; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Services; +use stdClass; + +/** + * @internal + * + * @group DatabaseLive + */ +final class EntityLiveTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $namespace = 'Tests\Support\Entity'; + + protected function setUp(): void + { + $this->setUpMethods[] = 'setUpAddNamespace'; + + parent::setUp(); + } + + protected function setUpAddNamespace(): void + { + Services::autoloader()->addNamespace( + 'Tests\Support\Entity', + SUPPORTPATH . 'Entity' + ); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->regressDatabase(); + } + + public function testEntityReturnsValuesWithCorrectTypes() + { + $entity = new class () extends Entity { + protected $casts = [ + 'id' => 'int', + 'active' => 'int-bool', + 'memo' => 'json', + ]; + }; + $model = new class () extends Model { + protected $table = 'users'; + protected $allowedFields = [ + 'username', 'active', 'memo', + ]; + protected $useTimestamps = true; + }; + $entity->fill(['username' => 'johnsmith', 'active' => false, 'memo' => ['foo', 'bar']]); + $model->save($entity); + + $user = $model->asObject(get_class($entity))->find(1); + + $this->assertSame(1, $user->id); + $this->assertSame('johnsmith', $user->username); + $this->assertFalse($user->active); + $this->assertSame(['foo', 'bar'], $user->memo); + $this->assertInstanceOf(Time::class, $user->created_at); + $this->assertInstanceOf(Time::class, $user->updated_at); + } + + /** + * @TODO Fix the object cast handler implementation. + */ + public function testCastObject(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'id' => 'int', + 'active' => 'int-bool', + 'memo' => 'object', + ]; + }; + $model = new class () extends Model { + protected $table = 'users'; + protected $allowedFields = [ + 'username', 'active', 'memo', + ]; + protected $useTimestamps = true; + }; + $entity->fill(['username' => 'johnsmith', 'active' => false, 'memo' => ['foo', 'bar']]); + $model->save($entity); + + $user = $model->asObject(get_class($entity))->find(1); + + $this->assertInstanceOf(stdClass::class, $user->memo); + $this->assertSame(['foo', 'bar'], (array) $user->memo); + } +} diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index dadea32b5680..6863c19df199 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -61,7 +61,7 @@ public function testSetArrayToPropertyNamedAttributes() 2 => 3, ], ]; - $this->assertSame($expected, $entity->toRawArray()); + $this->assertSame($expected, $entity->toDatabase()); } public function testSimpleSetAndGet(): void @@ -347,23 +347,31 @@ public function testCastIntBool(): void ]; }; - $entity->setAttributes(['active' => '1']); + $entity->active = '1'; $this->assertTrue($entity->active); - $entity->setAttributes(['active' => '0']); + $entity->active = '0'; + + $this->assertFalse($entity->active); + + $entity->active = 1; + + $this->assertTrue($entity->active); + + $entity->active = 0; $this->assertFalse($entity->active); $entity->active = true; $this->assertTrue($entity->active); - $this->assertSame(['active' => 1], $entity->toRawArray()); + $this->assertSame(['active' => 1], $entity->toDatabase()); $entity->active = false; $this->assertFalse($entity->active); - $this->assertSame(['active' => 0], $entity->toRawArray()); + $this->assertSame(['active' => 0], $entity->toDatabase()); } public function testCastFloat(): void @@ -423,11 +431,12 @@ public function testCastBoolean(): void public function testCastCSV(): void { - $entity = $this->getCastEntity(); + $entity = $this->getCastEntity(); + $data = ['foo', 'bar', 'bam']; $entity->twelfth = $data; - $result = $entity->toRawArray(); + $result = $entity->toDatabase(); $this->assertIsString($result['twelfth']); $this->assertSame('foo,bar,bam', $result['twelfth']); @@ -486,8 +495,9 @@ public function testCastArray(): void $entity->seventh = ['foo' => 'bar']; - $check = $this->getPrivateProperty($entity, 'attributes')['seventh']; - $this->assertSame(serialize(['foo' => 'bar']), $check); + $result = $entity->toDatabase(); + + $this->assertSame(serialize(['foo' => 'bar']), $result['seventh']); $this->assertSame(['foo' => 'bar'], $entity->seventh); } @@ -497,11 +507,12 @@ public function testCastArrayByStringSerialize(): void $entity->seventh = 'foobar'; + $result = $entity->toDatabase(); + // Should be a serialized string now... - $check = $this->getPrivateProperty($entity, 'attributes')['seventh']; - $this->assertSame(serialize('foobar'), $check); + $this->assertSame(serialize('foobar'), $result['seventh']); - $this->assertSame(['foobar'], $entity->seventh); + $this->assertSame('foobar', $entity->seventh); } public function testCastArrayByArraySerialize(): void @@ -510,9 +521,10 @@ public function testCastArrayByArraySerialize(): void $entity->seventh = ['foo' => 'bar']; + $result = $entity->toDatabase(); + // Should be a serialized string now... - $check = $this->getPrivateProperty($entity, 'attributes')['seventh']; - $this->assertSame(serialize(['foo' => 'bar']), $check); + $this->assertSame(serialize(['foo' => 'bar']), $result['seventh']); $this->assertSame(['foo' => 'bar'], $entity->seventh); } @@ -524,9 +536,10 @@ public function testCastArrayByFill(): void $data = ['seventh' => [1, 2, 3]]; $entity->fill($data); + $result = $entity->toDatabase(); + // Check if serialiazed - $check = $this->getPrivateProperty($entity, 'attributes')['seventh']; - $this->assertSame(serialize([1, 2, 3]), $check); + $this->assertSame(serialize([1, 2, 3]), $result['seventh']); // Check if unserialized $this->assertSame([1, 2, 3], $entity->seventh); } @@ -536,9 +549,10 @@ public function testCastArrayByConstructor(): void $data = ['seventh' => [1, 2, 3]]; $entity = $this->getCastEntity($data); + $result = $entity->toDatabase(); + // Check if serialiazed - $check = $this->getPrivateProperty($entity, 'attributes')['seventh']; - $this->assertSame(serialize([1, 2, 3]), $check); + $this->assertSame(serialize([1, 2, 3]), $result['seventh']); // Check if unserialized $this->assertSame([1, 2, 3], $entity->seventh); } @@ -584,12 +598,12 @@ public function testCastAsJSON(): void $entity->tenth = ['foo' => 'bar']; + $result = $entity->toDatabase(); + // Should be a JSON-encoded string now... - $check = $this->getPrivateProperty($entity, 'attributes')['tenth']; - $this->assertSame('{"foo":"bar"}', $check); + $this->assertSame('{"foo":"bar"}', $result['tenth']); - $this->assertInstanceOf('stdClass', $entity->tenth); - $this->assertSame(['foo' => 'bar'], (array) $entity->tenth); + $this->assertSame(['foo' => 'bar'], $entity->tenth); } public function testCastAsJSONArray(): void @@ -599,9 +613,10 @@ public function testCastAsJSONArray(): void $data = ['Sun', 'Mon', 'Tue']; $entity->eleventh = $data; + $result = $entity->toDatabase(); + // Should be a JSON-encoded string now... - $check = $this->getPrivateProperty($entity, 'attributes')['eleventh']; - $this->assertSame('["Sun","Mon","Tue"]', $check); + $this->assertSame('["Sun","Mon","Tue"]', $result['eleventh']); $this->assertSame($data, $entity->eleventh); } @@ -613,9 +628,10 @@ public function testCastAsJsonByFill(): void $data = ['eleventh' => [1, 2, 3]]; $entity->fill($data); + $result = $entity->toDatabase(); + // Check if serialiazed - $check = $this->getPrivateProperty($entity, 'attributes')['eleventh']; - $this->assertSame(json_encode([1, 2, 3]), $check); + $this->assertSame(json_encode([1, 2, 3]), $result['eleventh']); // Check if unserialized $this->assertSame([1, 2, 3], $entity->eleventh); } @@ -625,9 +641,10 @@ public function testCastAsJsonByConstructor(): void $data = ['eleventh' => [1, 2, 3]]; $entity = $this->getCastEntity($data); + $result = $entity->toDatabase(); + // Check if serialiazed - $check = $this->getPrivateProperty($entity, 'attributes')['eleventh']; - $this->assertSame(json_encode([1, 2, 3]), $check); + $this->assertSame(json_encode([1, 2, 3]), $result['eleventh']); // Check if unserialized $this->assertSame([1, 2, 3], $entity->eleventh); } @@ -651,6 +668,8 @@ public function testCastAsJSONErrorDepth(): void } $current = $value; $entity->tenth = $array; + + $entity->toDatabase(); } public function testCastAsJSONErrorUTF8(): void @@ -661,6 +680,8 @@ public function testCastAsJSONErrorUTF8(): void $entity = $this->getCastEntity(); $entity->tenth = "\xB1\x31"; + + $entity->toDatabase(); } /** @@ -675,7 +696,7 @@ public function testCastAsJSONSyntaxError(): void $entity = new Entity(); $entity->casts['dummy'] = 'json[array]'; - return $entity->castAs($value, 'dummy'); + return $entity->castAs($value, 'dummy', 'fromDatabase'); }, null, Entity::class))('{ this is bad string'); } @@ -692,7 +713,7 @@ public function testCastAsJSONAnotherErrorDepth(): void $entity = new Entity(); $entity->casts['dummy'] = 'json[array]'; - return $entity->castAs($value, 'dummy'); + return $entity->castAs($value, 'dummy', 'fromDatabase'); }, null, Entity::class))($string); } @@ -709,7 +730,7 @@ public function testCastAsJSONControlCharCheck(): void $entity = new Entity(); $entity->casts['dummy'] = 'json[array]'; - return $entity->castAs($value, 'dummy'); + return $entity->castAs($value, 'dummy', 'fromDatabase'); }, null, Entity::class))($string); } @@ -726,22 +747,24 @@ public function testCastAsJSONStateMismatch(): void $entity = new Entity(); $entity->casts['dummy'] = 'json[array]'; - return $entity->castAs($value, 'dummy'); + return $entity->castAs($value, 'dummy', 'fromDatabase'); }, null, Entity::class))($string); } public function testCastSetter(): void { - $string = '321 String with numbers 123'; - $entity = $this->getCastEntity(); - $entity->first = $string; + $entity = $this->getCastEntity(); $entity->cast(false); + $string = '321 String with numbers 123'; + $entity->first = $string; $this->assertIsString($entity->first); $this->assertSame($string, $entity->first); $entity->cast(true); + $string = '321 String with numbers 123'; + $entity->first = $string; $this->assertIsInt($entity->first); $this->assertSame((int) $string, $entity->first); @@ -863,7 +886,7 @@ public function testDataMappingIssetSwapped(): void $this->assertTrue($isset); $this->assertSame('222', $entity->bar); - $result = $entity->toRawArray(); + $result = $entity->toDatabase(); $this->assertSame([ 'foo' => '222', @@ -916,11 +939,11 @@ public function testAsArrayOnlyChanged(): void ], $result); } - public function testToRawArray(): void + public function testToDatabase(): void { $entity = $this->getEntity(); - $result = $entity->toRawArray(); + $result = $entity->toDatabase(); $this->assertSame([ 'foo' => null, @@ -930,12 +953,12 @@ public function testToRawArray(): void ], $result); } - public function testToRawArrayRecursive(): void + public function testToDatabaseRecursive(): void { $entity = $this->getEntity(); $entity->entity = $this->getEntity(); - $result = $entity->toRawArray(false, true); + $result = $entity->toDatabase(false, true); $this->assertSame([ 'foo' => null, @@ -951,12 +974,12 @@ public function testToRawArrayRecursive(): void ], $result); } - public function testToRawArrayOnlyChanged(): void + public function testToDatabaseOnlyChanged(): void { $entity = $this->getEntity(); $entity->bar = 'foo'; - $result = $entity->toRawArray(true); + $result = $entity->toDatabase(true); $this->assertSame([ 'bar' => 'bar:foo', @@ -1285,16 +1308,16 @@ protected function getCastNullableEntity() return new class () extends Entity { protected $attributes = [ 'string_null' => null, - 'string_empty' => null, + 'string_empty' => '', 'integer_null' => null, - 'integer_0' => null, + 'integer_0' => 0, 'string_value_not_null' => 'value', ]; protected $_original = [ 'string_null' => null, - 'string_empty' => null, + 'string_empty' => '', 'integer_null' => null, - 'integer_0' => null, + 'integer_0' => 0, 'string_value_not_null' => 'value', ]; @@ -1339,4 +1362,49 @@ protected function getCustomCastEntity() ]; }; } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/5905 + */ + public function testHasChangedCastsItem() + { + $data = [ + 'id' => '1', + 'name' => 'John', + 'age' => '35', + ]; + $entity = new class ($data) extends Entity { + protected $casts = [ + 'id' => 'integer', + 'name' => 'string', + 'age' => 'integer', + ]; + }; + $entity->syncOriginal(); + + $entity->age = 35; + + $this->assertFalse($entity->hasChanged('age')); + } + + public function testHasChangedCastsWholeEntity() + { + $data = [ + 'id' => '1', + 'name' => 'John', + 'age' => '35', + ]; + $entity = new class ($data) extends Entity { + protected $casts = [ + 'id' => 'integer', + 'name' => 'string', + 'age' => 'integer', + ]; + }; + $entity->syncOriginal(); + + $entity->age = 35; + + $this->assertFalse($entity->hasChanged()); + } }