diff --git a/system/Entity/Cast/DatetimeCast.php b/system/Entity/Cast/DatetimeCast.php index 72206ca883f7..e49df121987e 100644 --- a/system/Entity/Cast/DatetimeCast.php +++ b/system/Entity/Cast/DatetimeCast.php @@ -14,7 +14,7 @@ namespace CodeIgniter\Entity\Cast; use CodeIgniter\I18n\Time; -use DateTime; +use DateTimeInterface; use Exception; class DatetimeCast extends BaseCast @@ -32,7 +32,7 @@ public static function get($value, array $params = []) return $value; } - if ($value instanceof DateTime) { + if ($value instanceof DateTimeInterface) { return Time::createFromInstance($value); } diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 745b4cbe3dae..6541d985d33a 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -30,7 +30,6 @@ use CodeIgniter\Entity\Cast\URICast; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\I18n\Time; -use DateTime; use DateTimeInterface; use Exception; use JsonSerializable; @@ -79,14 +78,14 @@ class Entity implements JsonSerializable protected $casts = []; /** - * Custom convert handlers + * Custom convert handlers. * * @var array */ protected $castHandlers = []; /** - * Default convert handlers + * Default convert handlers. * * @var array */ @@ -128,29 +127,26 @@ class Entity implements JsonSerializable /** * The data caster. */ - protected DataCaster $dataCaster; + protected ?DataCaster $dataCaster = null; /** - * Holds info whenever properties have to be casted + * Holds info whenever properties have to be casted. */ private bool $_cast = true; /** - * Indicates whether all attributes are scalars (for optimization) + * Indicates whether all attributes are scalars (for optimization). */ private bool $_onlyScalars = true; /** * Allows filling in Entity parameters during construction. + * + * @param array $data */ public function __construct(?array $data = null) { - $this->dataCaster = new DataCaster( - array_merge($this->defaultCastHandlers, $this->castHandlers), - null, - null, - false, - ); + $this->dataCaster = $this->dataCaster(); $this->syncOriginal(); @@ -162,7 +158,7 @@ public function __construct(?array $data = null) * properties, using any `setCamelCasedProperty()` methods * that may or may not exist. * - * @param array $data + * @param array|bool|float|int|object|string|null> $data * * @return $this */ @@ -184,13 +180,16 @@ public function fill(?array $data = null) * 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 bool $onlyChanged If true, only return values that have changed since object creation + * @param bool $onlyChanged If true, only return values that have changed since object creation. * @param bool $cast If true, properties will be cast. * @param bool $recursive If true, inner entities will be cast as array as well. + * + * @return array */ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array { - $this->_cast = $cast; + $originalCast = $this->_cast; + $this->_cast = $cast; $keys = array_filter(array_keys($this->attributes), static fn ($key): bool => ! str_starts_with($key, '_')); @@ -219,7 +218,7 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu } } - $this->_cast = true; + $this->_cast = $originalCast; return $return; } @@ -227,8 +226,10 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu /** * Returns the raw values of the current attributes. * - * @param bool $onlyChanged If true, only return values that have changed since object creation + * @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. + * + * @return array */ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array { @@ -370,8 +371,6 @@ public function syncOriginal() * 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|null $key class property */ public function hasChanged(?string $key = null): bool { @@ -500,7 +499,9 @@ private function normalizeValue(mixed $data): mixed } /** - * Set raw data array without any mutations + * Set raw data array without any mutations. + * + * @param array $data * * @return $this */ @@ -513,23 +514,11 @@ public function injectRawData(array $data) return $this; } - /** - * Set raw data array without any mutations - * - * @return $this - * - * @deprecated Use injectRawData() instead. - */ - public function setAttributes(array $data) - { - return $this->injectRawData($data); - } - /** * Checks the datamap to see if this property name is being mapped, - * and returns the db column name, if any, or the original property name. + * and returns the DB column name, if any, or the original property name. * - * @return string db column name + * @return string Database column name. */ protected function mapProperty(string $key) { @@ -537,7 +526,7 @@ protected function mapProperty(string $key) return $key; } - if (! empty($this->datamap[$key])) { + if (array_key_exists($key, $this->datamap) && $this->datamap[$key] !== '') { return $this->datamap[$key]; } @@ -545,10 +534,10 @@ protected function mapProperty(string $key) } /** - * Converts the given string|timestamp|DateTime|Time instance + * Converts the given string|timestamp|DateTimeInterface instance * into the "CodeIgniter\I18n\Time" object. * - * @param DateTime|float|int|string|Time $value + * @param DateTimeInterface|float|int|string $value * * @return Time * @@ -568,22 +557,50 @@ protected function mutateDate($value) * @param string $attribute Attribute name * @param string $method Allowed to "get" and "set" * - * @return array|bool|float|int|object|string|null + * @return array|bool|float|int|object|string|null * * @throws CastException */ protected function castAs($value, string $attribute, string $method = 'get') { - return $this->dataCaster - // @TODO if $casts is readonly, we don't need the setTypes() method. - ->setTypes($this->casts) - ->castAs($value, $attribute, $method); + if ($this->dataCaster() instanceof DataCaster) { + return $this->dataCaster + // @TODO if $casts is readonly, we don't need the setTypes() method. + ->setTypes($this->casts) + ->castAs($value, $attribute, $method); + } + + return $value; + } + + /** + * Returns a DataCaster instance when casts are defined. + * If no casts are configured, no DataCaster is created and null is returned. + */ + protected function dataCaster(): ?DataCaster + { + if ($this->casts === []) { + $this->dataCaster = null; + + return null; + } + + if (! $this->dataCaster instanceof DataCaster) { + $this->dataCaster = new DataCaster( + array_merge($this->defaultCastHandlers, $this->castHandlers), + null, + null, + false, + ); + } + + return $this->dataCaster; } /** - * Support for json_encode() + * Support for json_encode(). * - * @return array + * @return array */ #[ReturnTypeWillChange] public function jsonSerialize() @@ -592,7 +609,7 @@ public function jsonSerialize() } /** - * Change the value of the private $_cast property + * Change the value of the private $_cast property. * * @return bool|Entity */ @@ -616,7 +633,7 @@ public function cast(?bool $cast = null) * $this->my_property = $p; * $this->setMyProperty() = $p; * - * @param array|bool|float|int|object|string|null $value + * @param array|bool|float|int|object|string|null $value * * @return void * @@ -646,7 +663,7 @@ public function __set(string $key, $value = null) } // If a "`set` + $key" method exists, it is also a setter. - if (method_exists($this, $method) && $method !== 'setAttributes') { + if (method_exists($this, $method)) { $this->{$method}($value); return; @@ -667,11 +684,9 @@ public function __set(string $key, $value = null) * $p = $this->my_property * $p = $this->getMyProperty() * - * @return array|bool|float|int|object|string|null + * @return array|bool|float|int|object|string|null * * @throws Exception - * - * @params string $key class property */ public function __get(string $key) { diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 42135c816ed9..7c19d9d09b89 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -16,6 +16,7 @@ use ArrayIterator; use ArrayObject; use Closure; +use CodeIgniter\DataCaster\DataCaster; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\HTTP\URI; use CodeIgniter\I18n\Time; @@ -75,6 +76,32 @@ public function testSetArrayToPropertyNamedAttributes(): void $this->assertSame($expected, $entity->toRawArray()); } + public function testSetGetAttributesMethod(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'foo' => null, + 'attributes' => null, + ]; + + public function setAttributes(string $value): self + { + $this->attributes['attributes'] = $value; + + return $this; + } + + public function getAttributes(): string + { + return $this->attributes['attributes']; + } + }; + + $entity->setAttributes('attributes'); + + $this->assertSame('attributes', $entity->getAttributes()); + } + public function testSimpleSetAndGet(): void { $entity = $this->getEntity(); @@ -358,11 +385,11 @@ public function testCastIntBool(): void ]; }; - $entity->setAttributes(['active' => '1']); + $entity->injectRawData(['active' => '1']); $this->assertTrue($entity->active); - $entity->setAttributes(['active' => '0']); + $entity->injectRawData(['active' => '0']); $this->assertFalse($entity->active); @@ -1078,6 +1105,38 @@ public function testAsArraySwapped(): void ], $result); } + public function testAsArrayRestoringCastStatus(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'first' => null, + ]; + protected $original = [ + 'first' => null, + ]; + protected $casts = [ + 'first' => 'integer', + ]; + }; + $entity->first = '2026 Year'; + + // Disabled casting properties, but we will allow casting in the method. + $entity->cast(false); + $beforeCast = $entity->cast(); + $result = $entity->toArray(true, true); + + $this->assertSame(2026, $result['first']); + $this->assertSame($beforeCast, $entity->cast()); + + // Enabled casting properties, but we will disallow casting in the method. + $entity->cast(true); + $beforeCast = $entity->cast(); + $result = $entity->toArray(true, false); + + $this->assertSame('2026 Year', $result['first']); + $this->assertSame($beforeCast, $entity->cast()); + } + public function testDataMappingIssetSwapped(): void { $entity = $this->getSimpleSwappedEntity(); @@ -1427,6 +1486,70 @@ public function testJsonSerializableEntity(): void $this->assertSame(json_encode($entity->toArray()), json_encode($entity)); } + public function testDataCasterInit(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'first' => '12345', + ]; + protected $casts = [ + 'first' => 'integer', + ]; + }; + + $getDataCaster = $this->getPrivateMethodInvoker($entity, 'dataCaster'); + + $this->assertInstanceOf(DataCaster::class, $getDataCaster()); + $this->assertInstanceOf(DataCaster::class, $this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame(12345, $entity->first); + + // Disable casting, but the DataCaster is initialized + $entity->cast(false); + $this->assertInstanceOf(DataCaster::class, $getDataCaster()); + $this->assertInstanceOf(DataCaster::class, $this->getPrivateProperty($entity, 'dataCaster')); + $this->assertIsString($entity->first); + + // Method castAs() ignore on the $_cast option + $this->assertSame(12345, $this->getPrivateMethodInvoker($entity, 'castAs')('12345', 'first')); + + // Restore casting + $entity->cast(true); + $this->assertInstanceOf(DataCaster::class, $getDataCaster()); + $this->assertInstanceOf(DataCaster::class, $this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame(12345, $entity->first); + } + + public function testDataCasterInitEmptyCasts(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'first' => '12345', + ]; + protected $casts = []; + }; + + $getDataCaster = $this->getPrivateMethodInvoker($entity, 'dataCaster'); + + $this->assertNull($getDataCaster()); + $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame('12345', $entity->first); + + // Disable casting, the DataCaster was not initialized + $entity->cast(false); + $this->assertNull($getDataCaster()); + $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame('12345', $entity->first); + + // Method castAs() depends on the $_cast option + $this->assertSame('12345', $this->getPrivateMethodInvoker($entity, 'castAs')('12345', 'first')); + + // Restore casting + $entity->cast(true); + $this->assertNull($getDataCaster()); + $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame('12345', $entity->first); + } + private function getEntity(): object { return new class () extends Entity { diff --git a/tests/system/Models/ValidationModelRuleGroupTest.php b/tests/system/Models/ValidationModelRuleGroupTest.php index 37e0ae4e3e6e..8059e1fee7d2 100644 --- a/tests/system/Models/ValidationModelRuleGroupTest.php +++ b/tests/system/Models/ValidationModelRuleGroupTest.php @@ -380,7 +380,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesTrueAndCallingCl // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -421,7 +421,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesFalse(): void // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -457,7 +457,7 @@ public function testInsertEntityValidateEntireRules(): void }; $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'field1' => 'value1', // field2 is missing 'field3' => '', diff --git a/tests/system/Models/ValidationModelTest.php b/tests/system/Models/ValidationModelTest.php index 7ec08c9e36ac..744238076e13 100644 --- a/tests/system/Models/ValidationModelTest.php +++ b/tests/system/Models/ValidationModelTest.php @@ -393,7 +393,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesTrueAndCallingCl // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -434,7 +434,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesFalse(): void // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -470,7 +470,7 @@ public function testInsertEntityValidateEntireRules(): void }; $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'field1' => 'value1', // field2 is missing 'field3' => '', diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c4dcb7d7d40f..642fe0b39645 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -111,6 +111,14 @@ parameter is ``true``. Previously, properties containing arrays were not recursi If you were relying on the old behavior where arrays remained unconverted, you will need to update your code. +Entity and DataCaster +--------------------- + +Previously, the ``DataCaster`` object was always initialized, even when type casting was not configured (an empty ``$casts = []`` array). + +``DataCaster`` is now created on demand and will be ``null`` when type casting is not configured. +While this change should not affect typical usage, developers should be aware that ``$dataCaster`` may now be nullable in some cases. + Encryption Handlers ------------------- @@ -200,12 +208,18 @@ Method Signature Changes - ``prepare(): void`` - ``respond(): void`` +Property Signature Changes +========================== + +- **Entity:** The protected property ``CodeIgniter\Entity\Entity::$dataCaster`` type has been changed from ``DataCaster`` to ``?DataCaster`` (nullable). + Removed Deprecated Items ======================== - **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. - **Cache:** The deprecated return type ``false`` for ``CodeIgniter\Cache\CacheInterface::getMetaData()`` has been replaced with ``null`` type. - **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. +- **Entity:** The deprecated ``CodeIgniter\Entity\Entity::setAttributes()`` has been removed. Use ``CodeIgniter\Entity\Entity::injectRawData()`` instead. - **IncomingRequest:** The deprecated methods has been removed: - ``CodeIgniter\HTTP\IncomingRequest\detectURI()`` - ``CodeIgniter\HTTP\IncomingRequest\detectPath()`` @@ -250,7 +264,6 @@ Libraries - **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. - **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object. - Commands ======== @@ -306,7 +319,6 @@ Changes - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. - **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. - ************ Deprecations ************ @@ -323,6 +335,7 @@ Bugs Fixed - **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. - **Cache:** Changed ``WincacheHandler::increment()`` and ``WincacheHandler::decrement()`` to return ``bool`` instead of ``mixed``. +- **Entity:** Calling ``CodeIgniter\Entity\Entity::toArray()`` always changed the value of ``$_cast`` to ``true``, instead of restoring the initial value. - **Toolbar:** Fixed **Maximum call stack size exceeded** crash when AJAX-like requests (HTMX, Turbo, Unpoly, etc.) were made on pages with Debug Toolbar enabled. See the repo's diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 62daf63e4f0c..56c17114138e 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -40,8 +40,6 @@ Assume you have a database table named ``users`` that has the following schema:: password - string created_at - datetime -.. important:: ``attributes`` is a reserved word for internal use. Prior to v4.4.0, if you use it as a column name, the Entity does not work correctly. - Create the Entity Class ======================= diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index c198e3caaf9e..1a9cdfdb2776 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -197,11 +197,6 @@ parameters: count: 2 path: ../../system/Encryption/Handlers/SodiumHandler.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index c4bccbba1584..f1cf09f775a0 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -2322,56 +2322,6 @@ parameters: count: 1 path: ../../system/Entity/Cast/URICast.php - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:__construct\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:__set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:castAs\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:fill\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:injectRawData\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:jsonSerialize\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:setAttributes\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:toRawArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - message: '#^Method CodeIgniter\\Exceptions\\PageNotFoundException\:\:lang\(\) has parameter \$args with no value type specified in iterable type array\.$#' count: 1