From 3eceb0c92c6231afc81040709745b78c48afd2ee Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 10 Jul 2026 23:01:29 +0200 Subject: [PATCH 1/2] 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 From 165666aa260335b3e190f23812587f281b266ef6 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Jul 2026 19:55:48 +0200 Subject: [PATCH 2/2] fix: make Entity::toRawArray datetime conversion BC and preserve microseconds --- system/DataCaster/Cast/TimestampCast.php | 12 +++- system/Entity/Entity.php | 24 ++++++-- .../DataConverter/DataConverterTest.php | 39 +++++++++++++ tests/system/Entity/EntityTest.php | 56 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.5.rst | 2 +- 5 files changed, 125 insertions(+), 8 deletions(-) diff --git a/system/DataCaster/Cast/TimestampCast.php b/system/DataCaster/Cast/TimestampCast.php index aa94266001ef..477f82ceacf6 100644 --- a/system/DataCaster/Cast/TimestampCast.php +++ b/system/DataCaster/Cast/TimestampCast.php @@ -14,6 +14,8 @@ namespace CodeIgniter\DataCaster\Cast; use CodeIgniter\I18n\Time; +use DateTimeInterface; +use Exception; /** * Class TimestampCast @@ -40,7 +42,15 @@ public static function set( array $params = [], ?object $helper = null, ): int { - 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 d9c0386e8816..9c0f237e2f5d 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -336,15 +336,27 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): } } - // Convert DateTime objects to string for user-facing calls + // Convert DateTime objects to string for user-facing calls (only for dates/datetime cast fields) if (! $recursive) { - $result = array_map(static function ($value) { + foreach ($result as $key => $value) { if ($value instanceof DateTimeInterface) { - return method_exists($value, '__toString') ? (string) $value : $value->format('Y-m-d H:i:s'); - } + $isDate = in_array($key, $this->dates, true); + if (! $isDate && isset($this->casts[$key])) { + $cast = $this->casts[$key]; + if (str_contains($cast, 'datetime')) { + $isDate = true; + } + } - return $value; - }, $result); + if ($isDate) { + if ((int) $value->format('u') > 0) { + $result[$key] = $value->format('Y-m-d H:i:s.u'); + } else { + $result[$key] = method_exists($value, '__toString') ? (string) $value : $value->format('Y-m-d H:i:s'); + } + } + } + } } return $result; diff --git a/tests/system/DataConverter/DataConverterTest.php b/tests/system/DataConverter/DataConverterTest.php index 6686618a181b..2425b683a210 100644 --- a/tests/system/DataConverter/DataConverterTest.php +++ b/tests/system/DataConverter/DataConverterTest.php @@ -15,6 +15,7 @@ use Closure; use CodeIgniter\DataCaster\Exceptions\CastException; +use CodeIgniter\Entity\Entity; use CodeIgniter\HTTP\URI; use CodeIgniter\I18n\Time; use CodeIgniter\Test\CIUnitTestCase; @@ -877,6 +878,44 @@ public function testExtractWithClosure(): void ], $array); } + public function testExtractEntityWithTimestampCast(): void + { + $converter = $this->createDataConverter([ + 'updated_at' => 'timestamp', + ]); + + $entity = new class () extends Entity { + protected $dates = []; + }; + $entity->injectRawData([ + 'updated_at' => Time::createFromTimestamp(1_784_092_299), + ]); + + $data = $converter->extract($entity); + + $this->assertSame(1_784_092_299, $data['updated_at']); + } + + public function testExtractEntityPreservesMicrosecondsWithDatetimeCast(): void + { + $converter = $this->createDataConverter( + ['updated_at' => 'datetime[us]'], + [], + db_connect(), + ); + + $entity = new class () extends Entity { + protected $dates = []; + }; + $entity->injectRawData([ + 'updated_at' => Time::createFromFormat('Y-m-d H:i:s.u', '2026-07-15 00:00:01.123456'), + ]); + + $data = $converter->extract($entity); + + $this->assertSame('2026-07-15 00:00:01.123456', $data['updated_at']); + } + /** * @param array $types * @param array $data diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index d2ecec0c6597..0f79856cb781 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -1543,6 +1543,62 @@ public function testToRawArrayRecursiveWithMixedAttributes(): void $this->assertIsString($result2['created_at']); } + public function testToRawArrayDoesNotConvertDateTimeWhenNotDeclaredInDatesOrCasts(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'custom_date' => null, + ]; + protected $dates = []; + protected $casts = []; + }; + + $entity->custom_date = Time::parse('2024-08-20 16:45:00'); + + $result = $entity->toRawArray(); + $this->assertInstanceOf(Time::class, $result['custom_date']); + } + + public function testToRawArrayRespectsCustomDateTimeStringConversion(): void + { + $customDateTime = new class ('2024-08-20 16:45:00') extends DateTime { + public function __toString(): string + { + return 'custom-formatted-date'; + } + }; + + $entity = new class () extends Entity { + protected $attributes = [ + 'created_at' => null, + ]; + protected $dates = []; + protected $casts = [ + 'created_at' => 'datetime', + ]; + }; + + $entity->created_at = $customDateTime; + + $result = $entity->toRawArray(); + $this->assertSame('custom-formatted-date', $result['created_at']); + } + + public function testToRawArrayConvertsNativeDateTimeToString(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'created_at' => null, + ]; + protected $dates = ['created_at']; + }; + + $entity->created_at = new DateTime('2024-08-20 16:45:00'); + + $result = $entity->toRawArray(); + $this->assertSame('2024-08-20 16:45:00', $result['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 d30352cf3d7f..59bef6db0185 100644 --- a/user_guide_src/source/changelogs/v4.7.5.rst +++ b/user_guide_src/source/changelogs/v4.7.5.rst @@ -30,7 +30,7 @@ Deprecations Bugs Fixed ********** -- **Entity:** Fixed a bug where ``toRawArray()`` returned ``Time`` objects instead of ISO-8601 strings for date fields. +- **Entity:** Fixed a bug where ``toRawArray()`` returned ``Time`` objects instead of 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.