From f6b1974c8019d17cc57d78f4fffc2696b4fca9b1 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 30 Oct 2025 08:29:25 +0100 Subject: [PATCH 1/6] feat(entity): deep change tracking for objects and arrays --- system/Entity/Entity.php | 93 ++- tests/system/Entity/EntityTest.php | 605 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 20 + user_guide_src/source/models/entities.rst | 20 + 4 files changed, 735 insertions(+), 3 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 0cbcdef00904..3872fc31872d 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Entity; +use BackedEnum; use CodeIgniter\DataCaster\DataCaster; use CodeIgniter\Entity\Cast\ArrayCast; use CodeIgniter\Entity\Cast\BooleanCast; @@ -33,6 +34,7 @@ use Exception; use JsonSerializable; use ReturnTypeWillChange; +use UnitEnum; /** * Entity encapsulation, for use with CodeIgniter\Model @@ -131,6 +133,11 @@ class Entity implements JsonSerializable */ private bool $_cast = true; + /** + * Indicates whether all attributes are scalars (for optimization) + */ + private bool $_onlyScalars = true; + /** * Allows filling in Entity parameters during construction. */ @@ -263,11 +270,24 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): /** * Ensures our "original" values match the current values. * + * Objects and arrays are normalized and JSON-encoded for reliable change detection, + * while scalars are stored as-is for performance. + * * @return $this */ public function syncOriginal() { - $this->original = $this->attributes; + $this->original = []; + $this->_onlyScalars = true; + + foreach ($this->attributes as $key => $value) { + if (is_object($value) || is_array($value)) { + $this->original[$key] = json_encode($this->normalizeValue($value)); + $this->_onlyScalars = false; + } else { + $this->original[$key] = $value; + } + } return $this; } @@ -283,7 +303,17 @@ public function hasChanged(?string $key = null): bool { // If no parameter was given then check all attributes if ($key === null) { - return $this->original !== $this->attributes; + if ($this->_onlyScalars) { + return $this->original !== $this->attributes; + } + + foreach (array_keys($this->attributes) as $attributeKey) { + if ($this->hasChanged($attributeKey)) { + return true; + } + } + + return false; } $dbColumn = $this->mapProperty($key); @@ -298,7 +328,64 @@ public function hasChanged(?string $key = null): bool return true; } - return $this->original[$dbColumn] !== $this->attributes[$dbColumn]; + // It was removed + if (array_key_exists($dbColumn, $this->original) && ! array_key_exists($dbColumn, $this->attributes)) { + return true; + } + + $originalValue = $this->original[$dbColumn]; + $currentValue = $this->attributes[$dbColumn]; + + // If original is a string, it was JSON-encoded (object or array) + if (is_string($originalValue) && (is_object($currentValue) || is_array($currentValue))) { + return $originalValue !== json_encode($this->normalizeValue($currentValue)); + } + + // For scalars, use direct comparison + return $originalValue !== $currentValue; + } + + /** + * Recursively normalize a value for comparison. + * Converts objects and arrays to a JSON-encodable format. + */ + private function normalizeValue(mixed $data): mixed + { + if (is_array($data)) { + $normalized = []; + + foreach ($data as $key => $value) { + $normalized[$key] = $this->normalizeValue($value); + } + + return $normalized; + } + + if (is_object($data)) { + // Check for Entity instance (use raw values, recursive) + if ($data instanceof Entity) { + $objectData = $data->toRawArray(false, true); + } elseif ($data instanceof JsonSerializable) { + $objectData = $data->jsonSerialize(); + } elseif (method_exists($data, 'toArray')) { + $objectData = $data->toArray(); + } elseif ($data instanceof UnitEnum) { + return [ + '__class' => $data::class, + '__enum' => $data instanceof BackedEnum ? $data->value : $data->name, + ]; + } else { + $objectData = get_object_vars($data); + } + + return [ + '__class' => $data::class, + '__data' => $this->normalizeValue($objectData), + ]; + } + + // Return scalars and null as-is + return $data; } /** diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 8abc287956b4..2633f0075e9b 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -21,9 +21,11 @@ use CodeIgniter\Test\ReflectionHelper; use DateTime; use DateTimeInterface; +use JsonSerializable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use ReflectionException; +use stdClass; use Tests\Support\Entity\Cast\CastBase64; use Tests\Support\Entity\Cast\CastPassParameters; use Tests\Support\Entity\Cast\NotExtendsBaseCast; @@ -1564,4 +1566,607 @@ private function getCustomCastEntity(): object ]; }; } + + public function testHasChangedWithScalarsOnlyUsesOptimization(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => 'test', + 'flag' => true, + ]; + }; + + // Sync original to set $_onlyScalars = true + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged()); + + $entity->id = 2; + + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedWithObjectsDoesNotUseOptimization(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'data' => null, + ]; + }; + + $entity->data = new stdClass(); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged()); + + $newObj = new stdClass(); + $newObj->test = 'value'; + $entity->data = $newObj; + + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedDetectsArrayChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => ['a', 'b', 'c'], + ]; + }; + + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('items')); + + $entity->items = ['a', 'b', 'd']; + + $this->assertTrue($entity->hasChanged('items')); + } + + public function testHasChangedDetectsNestedArrayChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => [ + 'level1' => [ + 'level2' => 'value', + ], + ], + ]; + }; + + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('data')); + + $entity->data = [ + 'level1' => [ + 'level2' => 'different', + ], + ]; + + $this->assertTrue($entity->hasChanged('data')); + } + + public function testHasChangedDetectsObjectPropertyChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'obj' => null, + ]; + }; + + $obj = new stdClass(); + $obj->prop = 'original'; + $entity->obj = $obj; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('obj')); + + $newObj = new stdClass(); + $newObj->prop = 'modified'; + $entity->obj = $newObj; + + $this->assertTrue($entity->hasChanged('obj')); + } + + public function testHasChangedWithNestedEntity(): void + { + $innerEntity = new SomeEntity(['foo' => 'bar']); + $outerEntity = new class () extends Entity { + protected $attributes = [ + 'nested' => null, + ]; + }; + $outerEntity->nested = $innerEntity; + $outerEntity->syncOriginal(); + + $this->assertFalse($outerEntity->hasChanged('nested')); + + $newInner = new SomeEntity(['foo' => 'baz']); + $outerEntity->nested = $newInner; + + $this->assertTrue($outerEntity->hasChanged('nested')); + } + + public function testHasChangedWithJsonSerializable(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => null, + ]; + }; + + $obj1 = new class () implements JsonSerializable { + public function jsonSerialize(): mixed + { + return ['value' => 'original']; + } + }; + + $entity->data = $obj1; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('data')); + + $obj2 = new class () implements JsonSerializable { + public function jsonSerialize(): mixed + { + return ['value' => 'modified']; + } + }; + + $entity->data = $obj2; + + $this->assertTrue($entity->hasChanged('data')); + } + + public function testHasChangedDoesNotDetectUnchangedObject(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'obj' => null, + ]; + }; + + $obj = new stdClass(); + $obj->prop = 'value'; + $entity->obj = $obj; + $entity->syncOriginal(); + + $sameObj = new stdClass(); + $sameObj->prop = 'value'; + $entity->obj = $sameObj; + + $this->assertFalse($entity->hasChanged('obj')); + } + + public function testSyncOriginalWithMixedTypes(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'scalar' => 'text', + 'number' => 42, + 'array' => [1, 2, 3], + 'object' => null, + 'null' => null, + 'boolean' => true, + ]; + }; + + $obj = new stdClass(); + $obj->prop = 'value'; + $entity->object = $obj; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + + // Scalars should be stored as-is + $this->assertSame('text', $original['scalar']); + $this->assertSame(42, $original['number']); + $this->assertNull($original['null']); + $this->assertTrue($original['boolean']); + + // Objects and arrays should be JSON-encoded + $this->assertIsString($original['array']); + $this->assertIsString($original['object']); + $this->assertSame(json_encode([1, 2, 3]), $original['array']); + } + + public function testSyncOriginalSetsHasOnlyScalarsFalseWithArrays(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'items' => ['a', 'b'], + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + $this->assertIsString($original['items']); + $this->assertSame(json_encode(['a', 'b']), $original['items']); + } + + public function testSyncOriginalSetsHasOnlyScalarsTrueWithOnlyScalars(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => 'test', + 'active' => true, + 'price' => 99.99, + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + $this->assertSame(1, $original['id']); + $this->assertSame('test', $original['name']); + $this->assertTrue($original['active']); + $this->assertEqualsWithDelta(99.99, $original['price'], PHP_FLOAT_EPSILON); + } + + public function testHasChangedWithObjectToArray(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => null, + ]; + }; + + $entity->data = new stdClass(); + $entity->syncOriginal(); + + $entity->data = []; + + $this->assertTrue($entity->hasChanged('data')); + } + + public function testHasChangedWithRemovedKey(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'foo' => 'bar', + ]; + }; + + $entity->syncOriginal(); + + unset($entity->foo); + + $this->assertTrue($entity->hasChanged('foo')); + } + + public function testNormalizeValueWithEntityToArray(): void + { + $innerEntity = new SomeEntity(['foo' => 'bar', 'bar' => 'baz']); + $entity = new class () extends Entity { + protected $attributes = [ + 'nested' => null, + ]; + }; + + $entity->nested = $innerEntity; + $entity->syncOriginal(); + + // Change inner entity property + $innerEntity2 = new SomeEntity(['foo' => 'changed', 'bar' => 'baz']); + $entity->nested = $innerEntity2; + + $this->assertTrue($entity->hasChanged('nested')); + } + + public function testHasChangedWithComplexNestedStructure(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'complex' => null, + ]; + }; + + $complex = [ + 'level1' => [ + 'level2' => [ + 'value' => 'original', + ], + ], + ]; + + $entity->complex = $complex; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('complex')); + + // Deep change + $complex['level1']['level2']['value'] = 'modified'; + $entity->complex = $complex; + + $this->assertTrue($entity->hasChanged('complex')); + } + + public function testHasChangedWithObjectContainingArray(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'obj' => null, + ]; + }; + + $obj = new stdClass(); + $obj->items = ['a', 'b', 'c']; + $entity->obj = $obj; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('obj')); + + // Change array inside object + $newObj = new stdClass(); + $newObj->items = ['a', 'b', 'd']; + $entity->obj = $newObj; + + $this->assertTrue($entity->hasChanged('obj')); + } + + public function testSyncOriginalAfterMultipleChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'value' => 'original', + ]; + }; + + $entity->syncOriginal(); + $this->assertFalse($entity->hasChanged()); + + $entity->value = 'changed1'; + $this->assertTrue($entity->hasChanged()); + + $entity->syncOriginal(); + $this->assertFalse($entity->hasChanged()); + + $entity->value = 'changed2'; + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedWithArrayOfObjects(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => null, + ]; + }; + + $obj1 = new stdClass(); + $obj1->id = 1; + $obj1->name = 'First'; + + $obj2 = new stdClass(); + $obj2->id = 2; + $obj2->name = 'Second'; + + $entity->items = [$obj1, $obj2]; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('items')); + + $obj3 = new stdClass(); + $obj3->id = 1; + $obj3->name = 'Modified'; + + $entity->items = [$obj3, $obj2]; + + $this->assertTrue($entity->hasChanged('items')); + } + + public function testHasChangedWithEmptyArrays(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'tags' => [], + ]; + }; + + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('tags')); + + $entity->tags = ['tag1']; + + $this->assertTrue($entity->hasChanged('tags')); + + $entity->syncOriginal(); + $entity->tags = []; + + $this->assertTrue($entity->hasChanged('tags')); + } + + public function testHasChangedWithObjectWithToArrayMethod(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'custom' => null, + ]; + }; + + // Create object with toArray() method + $obj1 = new class () { + /** + * @return array + */ + public function toArray(): array + { + return ['key' => 'value1']; + } + }; + + $entity->custom = $obj1; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('custom')); + + // Create different object with same class but different toArray() result + $obj2 = new class () { + /** + * @return array + */ + public function toArray(): array + { + return ['key' => 'value2']; + } + }; + + $entity->custom = $obj2; + + $this->assertTrue($entity->hasChanged('custom')); + } + + public function testHasChangedScalarOptimizationWithNullValues(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => null, + 'email' => null, + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + $this->assertSame(1, $original['id']); + $this->assertNull($original['name']); + $this->assertNull($original['email']); + + $this->assertFalse($entity->hasChanged()); + + // Change null to string + $entity->name = 'John'; + + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedDetectsNewPropertyAddition(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'existing' => 'value', + ]; + }; + + $entity->syncOriginal(); + + // Add new property + $entity->newProp = 'new value'; + + $this->assertTrue($entity->hasChanged()); + $this->assertTrue($entity->hasChanged('newProp')); + } + + public function testHasChangedWithBackedEnumString(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'status' => null, + ]; + }; + + $entity->status = StatusEnum::ACTIVE; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('status')); + + $entity->status = StatusEnum::PENDING; + + $this->assertTrue($entity->hasChanged('status')); + } + + public function testHasChangedWithBackedEnumInt(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'role' => null, + ]; + }; + + $entity->role = RoleEnum::USER; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('role')); + + $entity->role = RoleEnum::ADMIN; + + $this->assertTrue($entity->hasChanged('role')); + } + + public function testHasChangedWithUnitEnum(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'color' => null, + ]; + }; + + $entity->color = ColorEnum::RED; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('color')); + + $entity->color = ColorEnum::BLUE; + + $this->assertTrue($entity->hasChanged('color')); + } + + public function testHasChangedDoesNotDetectSameEnum(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'status' => null, + ]; + }; + + $entity->status = StatusEnum::ACTIVE; + $entity->syncOriginal(); + + $entity->status = StatusEnum::ACTIVE; + + $this->assertFalse($entity->hasChanged('status')); + } + + public function testSyncOriginalWithEnumValues(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'status' => StatusEnum::PENDING, + 'role' => RoleEnum::USER, + 'color' => ColorEnum::GREEN, + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + + // Enums should be JSON-encoded as objects + $this->assertIsString($original['status']); + $this->assertIsString($original['role']); + $this->assertIsString($original['color']); + + $statusData = json_decode($original['status'], true); + $this->assertSame(StatusEnum::class, $statusData['__class']); + $this->assertSame('pending', $statusData['__enum']); + + $roleData = json_decode($original['role'], true); + $this->assertSame(RoleEnum::class, $roleData['__class']); + $this->assertSame(1, $roleData['__enum']); + + $colorData = json_decode($original['color'], true); + $this->assertSame(ColorEnum::class, $colorData['__class']); + $this->assertSame('GREEN', $colorData['__enum']); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 565505b9b18e..020a91a90576 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -40,6 +40,26 @@ The ``insertBatch()`` and ``updateBatch()`` methods now honor model settings lik ``updateOnlyChanged`` and ``allowEmptyInserts``. This change ensures consistent handling across all insert/update operations. +Entity +------ + +The ``Entity::hasChanged()`` and ``Entity::syncOriginal()`` methods now perform deep comparison +for objects and arrays instead of shallow comparison. This means: + +- **Objects and arrays** are now JSON-encoded and normalized for comparison, detecting changes + in nested structures, object properties, and array elements. +- **Enums** (both ``BackedEnum`` and ``UnitEnum``) are properly tracked by their backing value + or case name. +- **Nested entities** (using ``toRawArray()``), ``JsonSerializable`` objects, and objects with + ``toArray()`` methods are recursively normalized for accurate change detection. +- **Scalar values** (strings, integers, floats, booleans, null) continue to use direct comparison + with an optimization when all entity attributes are scalars. + +Previously, changing an object property or an array containing objects would not be detected +as a change because only reference comparison was performed. Now, any modification to the internal +state of objects or arrays will be properly detected. If you relied on the old shallow comparison +behavior, you will need to update your code accordingly. + Interface Changes ================= diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 32f848941327..6dc780c3a326 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -363,3 +363,23 @@ attribute to check: Or to check the whole entity for changed values omit the parameter: .. literalinclude:: entities/023.php + +Deep Change Tracking +==================== + +.. versionadded:: 4.7.0 + +The Entity class performs **deep comparison** for objects and arrays to accurately detect changes in their internal state. + +Scalar Values +------------- + +For scalar values (strings, integers, floats, booleans, null), the Entity uses direct comparison. When all attributes +in an Entity are scalars, an optimized comparison is used for better performance. + +Objects and Arrays +------------------ + +For objects and arrays, the Entity JSON-encodes and normalizes the values for comparison. This means that modifications +to nested structures, object properties, array elements, nested entities (using ``toRawArray()``), enums (``BackedEnum`` +and ``UnitEnum``), and objects implementing ``JsonSerializable`` or ``toArray()`` will be properly detected. From f1f940dc8bf5290fe1ad4c7d5c3f5c709e6fe135 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 31 Oct 2025 08:10:50 +0100 Subject: [PATCH 2/6] use JSON_UNESCAPED_UNICODE and JSON_UNESCAPED_SLASHES with json_encode --- system/Entity/Entity.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 3872fc31872d..f07039eaa0ca 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -282,7 +282,7 @@ public function syncOriginal() foreach ($this->attributes as $key => $value) { if (is_object($value) || is_array($value)) { - $this->original[$key] = json_encode($this->normalizeValue($value)); + $this->original[$key] = json_encode($this->normalizeValue($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $this->_onlyScalars = false; } else { $this->original[$key] = $value; @@ -338,7 +338,7 @@ public function hasChanged(?string $key = null): bool // If original is a string, it was JSON-encoded (object or array) if (is_string($originalValue) && (is_object($currentValue) || is_array($currentValue))) { - return $originalValue !== json_encode($this->normalizeValue($currentValue)); + return $originalValue !== json_encode($this->normalizeValue($currentValue), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } // For scalars, use direct comparison From 0296b72253c994eeb5f40d86e0ca497284fc0903 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 31 Oct 2025 19:42:58 +0100 Subject: [PATCH 3/6] handle DateTimeInterface objects --- system/Entity/Entity.php | 6 ++++++ tests/system/Entity/EntityTest.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index f07039eaa0ca..8d84c69ce94c 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -31,6 +31,7 @@ use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\I18n\Time; use DateTime; +use DateTimeInterface; use Exception; use JsonSerializable; use ReturnTypeWillChange; @@ -374,6 +375,11 @@ private function normalizeValue(mixed $data): mixed '__class' => $data::class, '__enum' => $data instanceof BackedEnum ? $data->value : $data->name, ]; + } elseif ($data instanceof DateTimeInterface) { + return [ + '__class' => $data::class, + '__datetime' => $data->format(DATE_ATOM), + ]; } else { $objectData = get_object_vars($data); } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 2633f0075e9b..552b955d92f5 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -21,6 +21,7 @@ use CodeIgniter\Test\ReflectionHelper; use DateTime; use DateTimeInterface; +use DateTimeZone; use JsonSerializable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -2169,4 +2170,32 @@ public function testSyncOriginalWithEnumValues(): void $this->assertSame(ColorEnum::class, $colorData['__class']); $this->assertSame('GREEN', $colorData['__enum']); } + + public function testHasChangedWithDateTimeInterface(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'created_at' => null, + ]; + }; + + // Test with Time object + $entity->created_at = Time::parse('2024-01-01 12:00:00', 'UTC'); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('created_at')); + + $entity->created_at = Time::parse('2024-12-31 23:59:59', 'UTC'); + $this->assertTrue($entity->hasChanged('created_at')); + + $entity->syncOriginal(); + $entity->created_at = Time::parse('2024-12-31 23:59:59', 'UTC'); + $this->assertFalse($entity->hasChanged('created_at')); + + // Test timezone difference detection + $entity->created_at = new DateTime('2024-01-01 12:00:00', new DateTimeZone('UTC')); + $entity->syncOriginal(); + $entity->created_at = new DateTime('2024-01-01 12:00:00', new DateTimeZone('America/New_York')); + $this->assertTrue($entity->hasChanged('created_at')); + } } From 0d112cf2a0e15aea72fc68274a2532a800bb2592 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 31 Oct 2025 22:15:55 +0100 Subject: [PATCH 4/6] handle native SPL iterators --- system/Entity/Entity.php | 13 +++++---- tests/system/Entity/EntityTest.php | 46 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 8d84c69ce94c..709795928e04 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -35,6 +35,7 @@ use Exception; use JsonSerializable; use ReturnTypeWillChange; +use Traversable; use UnitEnum; /** @@ -370,16 +371,18 @@ private function normalizeValue(mixed $data): mixed $objectData = $data->jsonSerialize(); } elseif (method_exists($data, 'toArray')) { $objectData = $data->toArray(); + } elseif ($data instanceof Traversable) { + $objectData = iterator_to_array($data); + } elseif ($data instanceof DateTimeInterface) { + return [ + '__class' => $data::class, + '__datetime' => $data->format(DATE_RFC3339_EXTENDED), + ]; } elseif ($data instanceof UnitEnum) { return [ '__class' => $data::class, '__enum' => $data instanceof BackedEnum ? $data->value : $data->name, ]; - } elseif ($data instanceof DateTimeInterface) { - return [ - '__class' => $data::class, - '__datetime' => $data->format(DATE_ATOM), - ]; } else { $objectData = get_object_vars($data); } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 552b955d92f5..1cde386750de 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Entity; +use ArrayIterator; +use ArrayObject; use Closure; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\HTTP\URI; @@ -2198,4 +2200,48 @@ public function testHasChangedWithDateTimeInterface(): void $entity->created_at = new DateTime('2024-01-01 12:00:00', new DateTimeZone('America/New_York')); $this->assertTrue($entity->hasChanged('created_at')); } + + public function testHasChangedWithTraversable(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => null, + ]; + }; + + // Test with ArrayObject + $entity->items = new ArrayObject(['a', 'b', 'c']); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('items')); + + $entity->items = new ArrayObject(['a', 'b', 'd']); + $this->assertTrue($entity->hasChanged('items')); + + $entity->syncOriginal(); + $entity->items = new ArrayObject(['a', 'b', 'd']); + $this->assertFalse($entity->hasChanged('items')); + + // Test with ArrayIterator + $entity->items = new ArrayIterator(['x', 'y', 'z']); + $entity->syncOriginal(); + $entity->items = new ArrayIterator(['x', 'y', 'modified']); + $this->assertTrue($entity->hasChanged('items')); + + // Test with nested objects inside collection (verifies recursive normalization) + $obj1 = new stdClass(); + $obj1->name = 'first'; + + $obj2 = new stdClass(); + $obj2->name = 'second'; + + $entity->items = new ArrayObject([$obj1, $obj2]); + $entity->syncOriginal(); + + $obj3 = new stdClass(); + $obj3->name = 'modified'; + + $entity->items = new ArrayObject([$obj3, $obj2]); + $this->assertTrue($entity->hasChanged('items')); + } } From f51f62eea9e52cecfc463d1ba9a92cbcc01689b7 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 31 Oct 2025 22:21:58 +0100 Subject: [PATCH 5/6] update user guide --- user_guide_src/source/changelogs/v4.7.0.rst | 4 ++++ user_guide_src/source/models/entities.rst | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 020a91a90576..e89171212e84 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -50,6 +50,10 @@ for objects and arrays instead of shallow comparison. This means: in nested structures, object properties, and array elements. - **Enums** (both ``BackedEnum`` and ``UnitEnum``) are properly tracked by their backing value or case name. +- **DateTime objects** (``DateTimeInterface``) are compared using their ISO 8601 representation + including timezone information. +- **Collections** (``Traversable``) such as ``ArrayObject`` and ``ArrayIterator`` are converted + to arrays for comparison. - **Nested entities** (using ``toRawArray()``), ``JsonSerializable`` objects, and objects with ``toArray()`` methods are recursively normalized for accurate change detection. - **Scalar values** (strings, integers, floats, booleans, null) continue to use direct comparison diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 6dc780c3a326..280a04cfef4c 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -382,4 +382,5 @@ Objects and Arrays For objects and arrays, the Entity JSON-encodes and normalizes the values for comparison. This means that modifications to nested structures, object properties, array elements, nested entities (using ``toRawArray()``), enums (``BackedEnum`` -and ``UnitEnum``), and objects implementing ``JsonSerializable`` or ``toArray()`` will be properly detected. +and ``UnitEnum``), datetime objects (``DateTimeInterface``), collections (``Traversable``), and objects implementing +``JsonSerializable`` or ``toArray()`` will be properly detected. From 5be197b0d43ec9d1ccdc096014fe65e59cb42152 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 1 Nov 2025 06:55:53 +0100 Subject: [PATCH 6/6] handle value objects with __toString() --- system/Entity/Entity.php | 9 +++++ tests/system/Entity/EntityTest.php | 38 +++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ user_guide_src/source/models/entities.rst | 4 +-- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 709795928e04..59d18e9fe8a5 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -385,6 +385,15 @@ private function normalizeValue(mixed $data): mixed ]; } else { $objectData = get_object_vars($data); + + // Fallback for value objects with __toString() + // when properties are not accessible + if ($objectData === [] && method_exists($data, '__toString')) { + return [ + '__class' => $data::class, + '__string' => (string) $data, + ]; + } } return [ diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 1cde386750de..2af7ed91798e 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -2244,4 +2244,42 @@ public function testHasChangedWithTraversable(): void $entity->items = new ArrayObject([$obj3, $obj2]); $this->assertTrue($entity->hasChanged('items')); } + + public function testHasChangedWithValueObjectsUsingToString(): void + { + // Define a value object class + $emailClass = new class () { + public static function create(string $email): object + { + return new class ($email) { + public function __construct(private readonly string $email) + { + } + + public function __toString(): string + { + return $this->email; + } + }; + } + }; + + $entity = new class () extends Entity { + protected $attributes = [ + 'email' => null, + ]; + }; + + $entity->email = $emailClass::create('old@example.com'); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('email')); + + $entity->email = $emailClass::create('new@example.com'); + $this->assertTrue($entity->hasChanged('email')); + + $entity->syncOriginal(); + $entity->email = $emailClass::create('new@example.com'); + $this->assertFalse($entity->hasChanged('email')); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index e89171212e84..4738d11cda0a 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -54,6 +54,8 @@ for objects and arrays instead of shallow comparison. This means: including timezone information. - **Collections** (``Traversable``) such as ``ArrayObject`` and ``ArrayIterator`` are converted to arrays for comparison. +- **Value objects** with ``__toString()`` method are compared by their string representation when + properties are not accessible (fallback for objects with private properties). - **Nested entities** (using ``toRawArray()``), ``JsonSerializable`` objects, and objects with ``toArray()`` methods are recursively normalized for accurate change detection. - **Scalar values** (strings, integers, floats, booleans, null) continue to use direct comparison diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 280a04cfef4c..6aced331acee 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -382,5 +382,5 @@ Objects and Arrays For objects and arrays, the Entity JSON-encodes and normalizes the values for comparison. This means that modifications to nested structures, object properties, array elements, nested entities (using ``toRawArray()``), enums (``BackedEnum`` -and ``UnitEnum``), datetime objects (``DateTimeInterface``), collections (``Traversable``), and objects implementing -``JsonSerializable`` or ``toArray()`` will be properly detected. +and ``UnitEnum``), datetime objects (``DateTimeInterface``), collections (``Traversable``), value objects with +``__toString()``, and objects implementing ``JsonSerializable`` or ``toArray()`` will be properly detected.