From 3eceb0c92c6231afc81040709745b78c48afd2ee Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 10 Jul 2026 23:01:29 +0200 Subject: [PATCH] fix: Entity::toRawArray() converts DateTime objects to strings for non-recursive calls --- system/DataCaster/Cast/DatetimeCast.php | 12 +- system/Entity/Entity.php | 123 ++++++++++--------- tests/system/Entity/EntityTest.php | 122 ++++++++++++++++++ user_guide_src/source/changelogs/v4.7.5.rst | 2 + utils/phpstan-baseline/empty.notAllowed.neon | 2 +- utils/phpstan-baseline/loader.neon | 2 +- 6 files changed, 203 insertions(+), 60 deletions(-) diff --git a/system/DataCaster/Cast/DatetimeCast.php b/system/DataCaster/Cast/DatetimeCast.php index 398fdc7beed7..ed9e9e43b5c2 100644 --- a/system/DataCaster/Cast/DatetimeCast.php +++ b/system/DataCaster/Cast/DatetimeCast.php @@ -16,6 +16,8 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; +use DateTimeInterface; +use Exception; /** * Class DatetimeCast @@ -51,7 +53,15 @@ public static function set( array $params = [], ?object $helper = null, ): string { - if (! $value instanceof Time) { + if (is_string($value)) { + try { + $value = Time::parse($value); + } catch (Exception) { + self::invalidTypeValueError($value); + } + } + + if (! $value instanceof DateTimeInterface) { self::invalidTypeValueError($value); } diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index a67c44b6fd1c..d9c0386e8816 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -261,84 +261,93 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): // When returning everything if (! $onlyChanged) { - return $recursive - ? array_map($convert, $this->attributes) - : $this->attributes; - } - - // When filtering by changed values only - $return = []; - - foreach ($this->attributes as $key => $value) { - // Special handling for arrays of entities in recursive mode - // Skip hasChanged() and do per-entity comparison directly - if ($recursive && is_array($value) && $this->containsOnlyEntities($value)) { - $originalValue = $this->original[$key] ?? null; + $result = array_map($convert, $this->attributes); + } else { + // When filtering by changed values only + $result = []; + + foreach ($this->attributes as $key => $value) { + // Special handling for arrays of entities in recursive mode + // Skip hasChanged() and do per-entity comparison directly + if ($recursive && is_array($value) && $this->containsOnlyEntities($value)) { + $originalValue = $this->original[$key] ?? null; + + if (! is_string($originalValue)) { + // No original or invalid format, export all entities + $converted = []; + + foreach ($value as $idx => $item) { + $converted[$idx] = $item->toRawArray(false, true); + } + $result[$key] = $converted; + + continue; + } - if (! is_string($originalValue)) { - // No original or invalid format, export all entities - $converted = []; + // Decode original array structure for per-entity comparison + $originalArray = json_decode($originalValue, true); + $converted = []; foreach ($value as $idx => $item) { - $converted[$idx] = $item->toRawArray(false, true); + // Compare current entity against its original state + $currentNormalized = $this->normalizeValue($item); + $originalNormalized = $originalArray[$idx] ?? null; + + // Only include if changed, new, or can't determine + if ($originalNormalized === null || $currentNormalized !== $originalNormalized) { + $converted[$idx] = $item->toRawArray(false, true); + } } - $return[$key] = $converted; - continue; - } - - // Decode original array structure for per-entity comparison - $originalArray = json_decode($originalValue, true); - $converted = []; - - foreach ($value as $idx => $item) { - // Compare current entity against its original state - $currentNormalized = $this->normalizeValue($item); - $originalNormalized = $originalArray[$idx] ?? null; - - // Only include if changed, new, or can't determine - if ($originalNormalized === null || $currentNormalized !== $originalNormalized) { - $converted[$idx] = $item->toRawArray(false, true); + // Only include this property if at least one entity changed + if ($converted !== []) { + $result[$key] = $converted; } - } - // Only include this property if at least one entity changed - if ($converted !== []) { - $return[$key] = $converted; + continue; } - continue; - } + // For all other cases, use hasChanged() + if (! $this->hasChanged($key)) { + continue; + } - // For all other cases, use hasChanged() - if (! $this->hasChanged($key)) { - continue; - } + if ($recursive) { + // Special handling for arrays (mixed or not all entities) + if (is_array($value)) { + $converted = []; - if ($recursive) { - // Special handling for arrays (mixed or not all entities) - if (is_array($value)) { - $converted = []; + foreach ($value as $idx => $item) { + $converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item); + } + $result[$key] = $converted; - foreach ($value as $idx => $item) { - $converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item); + continue; } - $return[$key] = $converted; + + // default recursive conversion + $result[$key] = $convert($value); continue; } - // default recursive conversion - $return[$key] = $convert($value); - - continue; + // non-recursive changed value + $result[$key] = $convert($value); } + } + + // Convert DateTime objects to string for user-facing calls + if (! $recursive) { + $result = array_map(static function ($value) { + if ($value instanceof DateTimeInterface) { + return method_exists($value, '__toString') ? (string) $value : $value->format('Y-m-d H:i:s'); + } - // non-recursive changed value - $return[$key] = $value; + return $value; + }, $result); } - return $return; + return $result; } /** diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 5233a28deeaf..d2ecec0c6597 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -367,6 +367,41 @@ public function testDateMutationTimeToTime(): void $this->assertCloseEnoughString($dt->format('Y-m-d H:i:s'), $time->format('Y-m-d H:i:s')); } + public function testToRawArrayConvertsDateTimeToString(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'created_at' => null, + 'updated_at' => null, + ]; + protected $original = [ + 'created_at' => null, + 'updated_at' => null, + ]; + }; + + $entity->created_at = '2023-12-12 12:12:12'; + $entity->updated_at = '2023-12-13 13:13:13'; + + $raw = $entity->toRawArray(); + + // toRawArray() should return primitive types, not objects + $this->assertIsString($raw['created_at']); + $this->assertSame('2023-12-12 12:12:12', $raw['created_at']); + $this->assertIsString($raw['updated_at']); + $this->assertSame('2023-12-13 13:13:13', $raw['updated_at']); + + // Attributes themselves should still contain Time objects + $attrs = $this->getPrivateProperty($entity, 'attributes'); + $this->assertInstanceOf(Time::class, $attrs['created_at']); + $this->assertInstanceOf(Time::class, $attrs['updated_at']); + + // toArray() should still return Time objects (no regression) + $array = $entity->toArray(); + $this->assertInstanceOf(Time::class, $array['created_at']); + $this->assertInstanceOf(Time::class, $array['updated_at']); + } + public function testCastInteger(): void { $entity = $this->getCastEntity(); @@ -1421,6 +1456,93 @@ public function testToRawArrayOnlyChanged(): void ], $result); } + public function testToRawArrayConvertsDateTimeToStringOnlyWhenNonRecursive(): void + { + $entity = $this->getEntity(); + + // Non-recursive: DateTime becomes string + $entity->created_at = '2024-03-15 10:30:00'; + $raw = $entity->toRawArray(); + $this->assertIsString($raw['created_at']); + + // Recursive: DateTime stays Time + $recursive = $entity->toRawArray(false, true); + $this->assertInstanceOf(Time::class, $recursive['created_at']); + + // Non-recursive onlyChanged: also converts to string + $entity->syncOriginal(); + $entity->created_at = '2024-06-15 14:30:00'; + $changed = $entity->toRawArray(true); + $this->assertIsString($changed['created_at']); + + // Recursive onlyChanged: preserves Time + $changedRecursive = $entity->toRawArray(true, true); + $this->assertInstanceOf(Time::class, $changedRecursive['created_at']); + + // Null values stay null regardless of mode + $entity2 = $this->getEntity(); + $this->assertNull($entity2->toRawArray()['created_at']); + $this->assertNull($entity2->toRawArray(false, true)['created_at']); + + // toArray() should not be affected (still returns Time) + $arr = $entity->toArray(); + $this->assertInstanceOf(Time::class, $arr['createdAt']); + } + + public function testToRawArrayRecursivePreservesTimeObjectsInNestedEntities(): void + { + $child = $this->getEntity(); + $child->created_at = '2024-03-10 08:00:00'; + + $parent = $this->getEntity(); + $parent->created_at = '2024-03-15 10:30:00'; + $parent->entity = $child; + + $result = $parent->toRawArray(false, true); + + $this->assertInstanceOf(Time::class, $result['created_at']); + $this->assertIsArray($result['entity']); + $this->assertInstanceOf(Time::class, $result['entity']['created_at']); + + // Non-recursive: converts to string + $nonRecursive = $parent->toRawArray(); + $this->assertIsString($nonRecursive['created_at']); + + // toArray() uses datamapped keys + $arr = $parent->toArray(); + $this->assertInstanceOf(Time::class, $arr['createdAt']); + } + + public function testToRawArrayRecursiveWithMixedAttributes(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'name' => null, + 'created_at' => null, + 'count' => null, + ]; + protected $original = [ + 'name' => null, + 'created_at' => null, + 'count' => null, + ]; + }; + + $entity->name = 'test'; + $entity->count = 42; + $entity->created_at = '2024-08-20 16:45:00'; + + // Recursive: scalars unchanged, DateTime preserved as Time + $result = $entity->toRawArray(false, true); + $this->assertSame('test', $result['name']); + $this->assertSame(42, $result['count']); + $this->assertInstanceOf(Time::class, $result['created_at']); + + // Non-recursive: DateTime converted to string + $result2 = $entity->toRawArray(); + $this->assertIsString($result2['created_at']); + } + public function testFilledConstruction(): void { $data = [ diff --git a/user_guide_src/source/changelogs/v4.7.5.rst b/user_guide_src/source/changelogs/v4.7.5.rst index c4bb7900e07a..d30352cf3d7f 100644 --- a/user_guide_src/source/changelogs/v4.7.5.rst +++ b/user_guide_src/source/changelogs/v4.7.5.rst @@ -30,6 +30,8 @@ Deprecations Bugs Fixed ********** +- **Entity:** Fixed a bug where ``toRawArray()`` returned ``Time`` objects instead of ISO-8601 strings for date fields. + - **Logger:** Fixed a bug where interpolating a log message with array or non-stringable context values could raise PHP warnings or errors. See the repo's diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index 827126f98676..796292761d9c 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 212 errors +# total 205 errors parameters: ignoreErrors: diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 5214bfa1141b..f11a86e7b989 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 1819 errors +# total 1812 errors includes: - argument.type.neon