Skip to content

Commit 877d1da

Browse files
authored
feat(entity): properly convert arrays of entities in toRawArray() (codeigniter4#9841)
* feat: make toRawArray() properly convert arrays of entities * add changelog * update changelog
1 parent fe1e944 commit 877d1da

File tree

3 files changed

+241
-17
lines changed

3 files changed

+241
-17
lines changed

system/Entity/Entity.php

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -232,37 +232,109 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu
232232
*/
233233
public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array
234234
{
235-
$return = [];
235+
$convert = static function ($value) use (&$convert, $recursive) {
236+
if (! $recursive) {
237+
return $value;
238+
}
236239

237-
if (! $onlyChanged) {
238-
if ($recursive) {
239-
return array_map(static function ($value) use ($onlyChanged, $recursive) {
240-
if ($value instanceof self) {
241-
$value = $value->toRawArray($onlyChanged, $recursive);
242-
} elseif (is_callable([$value, 'toRawArray'])) {
243-
$value = $value->toRawArray();
244-
}
240+
if ($value instanceof self) {
241+
// Always output full array for nested entities
242+
return $value->toRawArray(false, true);
243+
}
244+
245+
if (is_array($value)) {
246+
$result = [];
245247

246-
return $value;
247-
}, $this->attributes);
248+
foreach ($value as $k => $v) {
249+
$result[$k] = $convert($v);
250+
}
251+
252+
return $result;
253+
}
254+
255+
if (is_object($value) && is_callable([$value, 'toRawArray'])) {
256+
return $value->toRawArray();
248257
}
249258

250-
return $this->attributes;
259+
return $value;
260+
};
261+
262+
// When returning everything
263+
if (! $onlyChanged) {
264+
return $recursive
265+
? array_map($convert, $this->attributes)
266+
: $this->attributes;
251267
}
252268

269+
// When filtering by changed values only
270+
$return = [];
271+
253272
foreach ($this->attributes as $key => $value) {
273+
// Special handling for arrays of entities in recursive mode
274+
// Skip hasChanged() and do per-entity comparison directly
275+
if ($recursive && is_array($value) && $this->containsOnlyEntities($value)) {
276+
$originalValue = $this->original[$key] ?? null;
277+
278+
if (! is_string($originalValue)) {
279+
// No original or invalid format, export all entities
280+
$converted = [];
281+
282+
foreach ($value as $idx => $item) {
283+
$converted[$idx] = $item->toRawArray(false, true);
284+
}
285+
$return[$key] = $converted;
286+
287+
continue;
288+
}
289+
290+
// Decode original array structure for per-entity comparison
291+
$originalArray = json_decode($originalValue, true);
292+
$converted = [];
293+
294+
foreach ($value as $idx => $item) {
295+
// Compare current entity against its original state
296+
$currentNormalized = $this->normalizeValue($item);
297+
$originalNormalized = $originalArray[$idx] ?? null;
298+
299+
// Only include if changed, new, or can't determine
300+
if ($originalNormalized === null || $currentNormalized !== $originalNormalized) {
301+
$converted[$idx] = $item->toRawArray(false, true);
302+
}
303+
}
304+
305+
// Only include this property if at least one entity changed
306+
if ($converted !== []) {
307+
$return[$key] = $converted;
308+
}
309+
310+
continue;
311+
}
312+
313+
// For all other cases, use hasChanged()
254314
if (! $this->hasChanged($key)) {
255315
continue;
256316
}
257317

258318
if ($recursive) {
259-
if ($value instanceof self) {
260-
$value = $value->toRawArray($onlyChanged, $recursive);
261-
} elseif (is_callable([$value, 'toRawArray'])) {
262-
$value = $value->toRawArray();
319+
// Special handling for arrays (mixed or not all entities)
320+
if (is_array($value)) {
321+
$converted = [];
322+
323+
foreach ($value as $idx => $item) {
324+
$converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item);
325+
}
326+
$return[$key] = $converted;
327+
328+
continue;
263329
}
330+
331+
// default recursive conversion
332+
$return[$key] = $convert($value);
333+
334+
continue;
264335
}
265336

337+
// non-recursive changed value
266338
$return[$key] = $value;
267339
}
268340

@@ -347,6 +419,27 @@ public function hasChanged(?string $key = null): bool
347419
return $originalValue !== $currentValue;
348420
}
349421

422+
/**
423+
* Checks if an array contains only Entity instances.
424+
* This allows optimization for per-entity change tracking.
425+
*
426+
* @param array<int|string, mixed> $data
427+
*/
428+
private function containsOnlyEntities(array $data): bool
429+
{
430+
if ($data === []) {
431+
return false;
432+
}
433+
434+
foreach ($data as $item) {
435+
if (! $item instanceof self) {
436+
return false;
437+
}
438+
}
439+
440+
return true;
441+
}
442+
350443
/**
351444
* Recursively normalize a value for comparison.
352445
* Converts objects and arrays to a JSON-encodable format.
@@ -365,7 +458,7 @@ private function normalizeValue(mixed $data): mixed
365458

366459
if (is_object($data)) {
367460
// Check for Entity instance (use raw values, recursive)
368-
if ($data instanceof Entity) {
461+
if ($data instanceof self) {
369462
$objectData = $data->toRawArray(false, true);
370463
} elseif ($data instanceof JsonSerializable) {
371464
$objectData = $data->jsonSerialize();

tests/system/Entity/EntityTest.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,6 +1181,132 @@ public function testToRawArrayRecursive(): void
11811181
], $result);
11821182
}
11831183

1184+
public function testToRawArrayRecursiveWithArray(): void
1185+
{
1186+
$entity = $this->getEntity();
1187+
$entity->entities = [$this->getEntity(), $this->getEntity()];
1188+
1189+
$result = $entity->toRawArray(false, true);
1190+
1191+
$this->assertSame([
1192+
'foo' => null,
1193+
'bar' => null,
1194+
'default' => 'sumfin',
1195+
'created_at' => null,
1196+
'entities' => [[
1197+
'foo' => null,
1198+
'bar' => null,
1199+
'default' => 'sumfin',
1200+
'created_at' => null,
1201+
], [
1202+
'foo' => null,
1203+
'bar' => null,
1204+
'default' => 'sumfin',
1205+
'created_at' => null,
1206+
]],
1207+
], $result);
1208+
}
1209+
1210+
public function testToRawArrayRecursiveOnlyChangedWithArray(): void
1211+
{
1212+
$first = $this->getEntity();
1213+
$second = $this->getEntity();
1214+
1215+
$entity = $this->getEntity();
1216+
$entity->entities = [$first];
1217+
$entity->syncOriginal();
1218+
1219+
$entity->entities = [$first, $second];
1220+
1221+
$result = $entity->toRawArray(true, true);
1222+
1223+
$this->assertSame([
1224+
'entities' => [1 => [
1225+
'foo' => null,
1226+
'bar' => null,
1227+
'default' => 'sumfin',
1228+
'created_at' => null,
1229+
]],
1230+
], $result);
1231+
}
1232+
1233+
public function testToRawArrayRecursiveOnlyChangedWithArrayEntityModified(): void
1234+
{
1235+
$first = $this->getEntity();
1236+
$second = $this->getEntity();
1237+
$first->foo = 'original';
1238+
$second->foo = 'also_original';
1239+
1240+
$entity = $this->getEntity();
1241+
$entity->entities = [$first, $second];
1242+
$entity->syncOriginal();
1243+
1244+
$second->foo = 'modified';
1245+
1246+
$result = $entity->toRawArray(true, true);
1247+
1248+
$this->assertSame([
1249+
'entities' => [1 => [
1250+
'foo' => 'modified',
1251+
'bar' => null,
1252+
'default' => 'sumfin',
1253+
'created_at' => null,
1254+
]],
1255+
], $result);
1256+
}
1257+
1258+
public function testToRawArrayRecursiveOnlyChangedWithArrayMultipleEntitiesModified(): void
1259+
{
1260+
$first = $this->getEntity();
1261+
$second = $this->getEntity();
1262+
$third = $this->getEntity();
1263+
$first->foo = 'first';
1264+
$second->foo = 'second';
1265+
$third->foo = 'third';
1266+
1267+
$entity = $this->getEntity();
1268+
$entity->entities = [$first, $second, $third];
1269+
$entity->syncOriginal();
1270+
1271+
$first->foo = 'first_modified';
1272+
$third->foo = 'third_modified';
1273+
1274+
$result = $entity->toRawArray(true, true);
1275+
1276+
$this->assertSame([
1277+
'entities' => [
1278+
0 => [
1279+
'foo' => 'first_modified',
1280+
'bar' => null,
1281+
'default' => 'sumfin',
1282+
'created_at' => null,
1283+
],
1284+
2 => [
1285+
'foo' => 'third_modified',
1286+
'bar' => null,
1287+
'default' => 'sumfin',
1288+
'created_at' => null,
1289+
],
1290+
],
1291+
], $result);
1292+
}
1293+
1294+
public function testToRawArrayRecursiveOnlyChangedWithArrayNoEntitiesModified(): void
1295+
{
1296+
$first = $this->getEntity();
1297+
$second = $this->getEntity();
1298+
$first->foo = 'unchanged';
1299+
$second->foo = 'also_unchanged';
1300+
1301+
$entity = $this->getEntity();
1302+
$entity->entities = [$first, $second];
1303+
$entity->syncOriginal();
1304+
1305+
$result = $entity->toRawArray(true, true);
1306+
1307+
$this->assertSame([], $result);
1308+
}
1309+
11841310
public function testToRawArrayOnlyChanged(): void
11851311
{
11861312
$entity = $this->getEntity();

user_guide_src/source/changelogs/v4.7.0.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ as a change because only reference comparison was performed. Now, any modificati
106106
state of objects or arrays will be properly detected. If you relied on the old shallow comparison
107107
behavior, you will need to update your code accordingly.
108108

109+
The ``Entity::toRawArray()`` method now properly converts arrays of entities when the ``$recursive``
110+
parameter is ``true``. Previously, properties containing arrays were not recursively processed.
111+
If you were relying on the old behavior where arrays remained unconverted, you will need to update
112+
your code.
113+
109114
Interface Changes
110115
=================
111116

0 commit comments

Comments
 (0)