From 20d7516e322bbd4b886a2c85a2e83f732b9cf7a9 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 dcab974ecd1f7921356b9a4dbdcfe08d1a218098 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 70b27b15a600..6c2008887f71 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -66,6 +66,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 f3b50d88c3a81a3fc733f218d6148075564a04e2 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 6c2008887f71..cae90a69cc85 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -66,10 +66,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 =================