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(); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 70b27b15a600..cae90a69cc85 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 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 =================