From c9751ee5cb2e434c1ec318094480b0a34053270d Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 30 Nov 2025 17:46:51 +0100 Subject: [PATCH 1/3] feat: make toRawArray() properly convert arrays of entities --- system/Entity/Entity.php | 127 +++++++++++++++++++++++++---- tests/system/Entity/EntityTest.php | 126 ++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 17 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 59d18e9fe8a5..745b4cbe3dae 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -232,37 +232,109 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu */ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array { - $return = []; + $convert = static function ($value) use (&$convert, $recursive) { + if (! $recursive) { + return $value; + } - if (! $onlyChanged) { - 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(); - } + if ($value instanceof self) { + // Always output full array for nested entities + return $value->toRawArray(false, true); + } + + if (is_array($value)) { + $result = []; - return $value; - }, $this->attributes); + foreach ($value as $k => $v) { + $result[$k] = $convert($v); + } + + return $result; + } + + if (is_object($value) && is_callable([$value, 'toRawArray'])) { + return $value->toRawArray(); } - return $this->attributes; + return $value; + }; + + // 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; + + if (! is_string($originalValue)) { + // No original or invalid format, export all entities + $converted = []; + + foreach ($value as $idx => $item) { + $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 !== []) { + $return[$key] = $converted; + } + + continue; + } + + // For all other cases, use hasChanged() if (! $this->hasChanged($key)) { continue; } if ($recursive) { - if ($value instanceof self) { - $value = $value->toRawArray($onlyChanged, $recursive); - } elseif (is_callable([$value, 'toRawArray'])) { - $value = $value->toRawArray(); + // 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); + } + $return[$key] = $converted; + + continue; } + + // default recursive conversion + $return[$key] = $convert($value); + + continue; } + // non-recursive changed value $return[$key] = $value; } @@ -347,6 +419,27 @@ public function hasChanged(?string $key = null): bool return $originalValue !== $currentValue; } + /** + * Checks if an array contains only Entity instances. + * This allows optimization for per-entity change tracking. + * + * @param array $data + */ + private function containsOnlyEntities(array $data): bool + { + if ($data === []) { + return false; + } + + foreach ($data as $item) { + if (! $item instanceof self) { + return false; + } + } + + return true; + } + /** * Recursively normalize a value for comparison. * Converts objects and arrays to a JSON-encodable format. @@ -365,7 +458,7 @@ private function normalizeValue(mixed $data): mixed if (is_object($data)) { // Check for Entity instance (use raw values, recursive) - if ($data instanceof Entity) { + if ($data instanceof self) { $objectData = $data->toRawArray(false, true); } elseif ($data instanceof JsonSerializable) { $objectData = $data->jsonSerialize(); diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 76fe1589ec08..42135c816ed9 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -1181,6 +1181,132 @@ public function testToRawArrayRecursive(): void ], $result); } + public function testToRawArrayRecursiveWithArray(): void + { + $entity = $this->getEntity(); + $entity->entities = [$this->getEntity(), $this->getEntity()]; + + $result = $entity->toRawArray(false, true); + + $this->assertSame([ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + 'entities' => [[ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ], [ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArray(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + + $entity = $this->getEntity(); + $entity->entities = [$first]; + $entity->syncOriginal(); + + $entity->entities = [$first, $second]; + + $result = $entity->toRawArray(true, true); + + $this->assertSame([ + 'entities' => [1 => [ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArrayEntityModified(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + $first->foo = 'original'; + $second->foo = 'also_original'; + + $entity = $this->getEntity(); + $entity->entities = [$first, $second]; + $entity->syncOriginal(); + + $second->foo = 'modified'; + + $result = $entity->toRawArray(true, true); + + $this->assertSame([ + 'entities' => [1 => [ + 'foo' => 'modified', + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArrayMultipleEntitiesModified(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + $third = $this->getEntity(); + $first->foo = 'first'; + $second->foo = 'second'; + $third->foo = 'third'; + + $entity = $this->getEntity(); + $entity->entities = [$first, $second, $third]; + $entity->syncOriginal(); + + $first->foo = 'first_modified'; + $third->foo = 'third_modified'; + + $result = $entity->toRawArray(true, true); + + $this->assertSame([ + 'entities' => [ + 0 => [ + 'foo' => 'first_modified', + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ], + 2 => [ + 'foo' => 'third_modified', + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ], + ], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArrayNoEntitiesModified(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + $first->foo = 'unchanged'; + $second->foo = 'also_unchanged'; + + $entity = $this->getEntity(); + $entity->entities = [$first, $second]; + $entity->syncOriginal(); + + $result = $entity->toRawArray(true, true); + + $this->assertSame([], $result); + } + public function testToRawArrayOnlyChanged(): void { $entity = $this->getEntity(); From 5dbed02517b1a4ae1376c6bffe382374fcb33c06 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 14 Dec 2025 18:52:28 +0100 Subject: [PATCH 2/3] add changelog --- user_guide_src/source/changelogs/v4.7.0.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 5cfbbf6815f5..122e966aaa40 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -106,6 +106,11 @@ as a change because only reference comparison was performed. Now, any modificati 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. +The ``Entity::toRawArray()`` method now properly converts arrays (including arrays of entities) +when the ``$recursive`` parameter is ``true``. Previously, properties containing arrays were not +recursively processed. If you were relying on the old behavior where arrays remained unconverted, +you will need to update your code. + Interface Changes ================= From c99b7c0878622437b138affb7225ea20c83f0123 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 14 Dec 2025 19:12:06 +0100 Subject: [PATCH 3/3] update changelog --- user_guide_src/source/changelogs/v4.7.0.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 122e966aaa40..2778dc0f76fc 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -106,10 +106,10 @@ as a change because only reference comparison was performed. Now, any modificati 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. -The ``Entity::toRawArray()`` method now properly converts arrays (including arrays of entities) -when the ``$recursive`` parameter is ``true``. Previously, properties containing arrays were not -recursively processed. If you were relying on the old behavior where arrays remained unconverted, -you will need to update your code. +The ``Entity::toRawArray()`` method now properly converts arrays of entities when the ``$recursive`` +parameter is ``true``. Previously, properties containing arrays were not recursively processed. +If you were relying on the old behavior where arrays remained unconverted, you will need to update +your code. Interface Changes =================