Skip to content

Commit 3d993c5

Browse files
authored
feat(entity): deep change tracking for objects and arrays (#9779)
* feat(entity): deep change tracking for objects and arrays * use JSON_UNESCAPED_UNICODE and JSON_UNESCAPED_SLASHES with json_encode * handle DateTimeInterface objects * handle native SPL iterators * update user guide * handle value objects with __toString()
1 parent c9d3dcd commit 3d993c5

File tree

4 files changed

+873
-3
lines changed

4 files changed

+873
-3
lines changed

system/Entity/Entity.php

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace CodeIgniter\Entity;
1515

16+
use BackedEnum;
1617
use CodeIgniter\DataCaster\DataCaster;
1718
use CodeIgniter\Entity\Cast\ArrayCast;
1819
use CodeIgniter\Entity\Cast\BooleanCast;
@@ -30,9 +31,12 @@
3031
use CodeIgniter\Entity\Exceptions\CastException;
3132
use CodeIgniter\I18n\Time;
3233
use DateTime;
34+
use DateTimeInterface;
3335
use Exception;
3436
use JsonSerializable;
3537
use ReturnTypeWillChange;
38+
use Traversable;
39+
use UnitEnum;
3640

3741
/**
3842
* Entity encapsulation, for use with CodeIgniter\Model
@@ -131,6 +135,11 @@ class Entity implements JsonSerializable
131135
*/
132136
private bool $_cast = true;
133137

138+
/**
139+
* Indicates whether all attributes are scalars (for optimization)
140+
*/
141+
private bool $_onlyScalars = true;
142+
134143
/**
135144
* Allows filling in Entity parameters during construction.
136145
*/
@@ -263,11 +272,24 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false):
263272
/**
264273
* Ensures our "original" values match the current values.
265274
*
275+
* Objects and arrays are normalized and JSON-encoded for reliable change detection,
276+
* while scalars are stored as-is for performance.
277+
*
266278
* @return $this
267279
*/
268280
public function syncOriginal()
269281
{
270-
$this->original = $this->attributes;
282+
$this->original = [];
283+
$this->_onlyScalars = true;
284+
285+
foreach ($this->attributes as $key => $value) {
286+
if (is_object($value) || is_array($value)) {
287+
$this->original[$key] = json_encode($this->normalizeValue($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
288+
$this->_onlyScalars = false;
289+
} else {
290+
$this->original[$key] = $value;
291+
}
292+
}
271293

272294
return $this;
273295
}
@@ -283,7 +305,17 @@ public function hasChanged(?string $key = null): bool
283305
{
284306
// If no parameter was given then check all attributes
285307
if ($key === null) {
286-
return $this->original !== $this->attributes;
308+
if ($this->_onlyScalars) {
309+
return $this->original !== $this->attributes;
310+
}
311+
312+
foreach (array_keys($this->attributes) as $attributeKey) {
313+
if ($this->hasChanged($attributeKey)) {
314+
return true;
315+
}
316+
}
317+
318+
return false;
287319
}
288320

289321
$dbColumn = $this->mapProperty($key);
@@ -298,7 +330,80 @@ public function hasChanged(?string $key = null): bool
298330
return true;
299331
}
300332

301-
return $this->original[$dbColumn] !== $this->attributes[$dbColumn];
333+
// It was removed
334+
if (array_key_exists($dbColumn, $this->original) && ! array_key_exists($dbColumn, $this->attributes)) {
335+
return true;
336+
}
337+
338+
$originalValue = $this->original[$dbColumn];
339+
$currentValue = $this->attributes[$dbColumn];
340+
341+
// If original is a string, it was JSON-encoded (object or array)
342+
if (is_string($originalValue) && (is_object($currentValue) || is_array($currentValue))) {
343+
return $originalValue !== json_encode($this->normalizeValue($currentValue), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
344+
}
345+
346+
// For scalars, use direct comparison
347+
return $originalValue !== $currentValue;
348+
}
349+
350+
/**
351+
* Recursively normalize a value for comparison.
352+
* Converts objects and arrays to a JSON-encodable format.
353+
*/
354+
private function normalizeValue(mixed $data): mixed
355+
{
356+
if (is_array($data)) {
357+
$normalized = [];
358+
359+
foreach ($data as $key => $value) {
360+
$normalized[$key] = $this->normalizeValue($value);
361+
}
362+
363+
return $normalized;
364+
}
365+
366+
if (is_object($data)) {
367+
// Check for Entity instance (use raw values, recursive)
368+
if ($data instanceof Entity) {
369+
$objectData = $data->toRawArray(false, true);
370+
} elseif ($data instanceof JsonSerializable) {
371+
$objectData = $data->jsonSerialize();
372+
} elseif (method_exists($data, 'toArray')) {
373+
$objectData = $data->toArray();
374+
} elseif ($data instanceof Traversable) {
375+
$objectData = iterator_to_array($data);
376+
} elseif ($data instanceof DateTimeInterface) {
377+
return [
378+
'__class' => $data::class,
379+
'__datetime' => $data->format(DATE_RFC3339_EXTENDED),
380+
];
381+
} elseif ($data instanceof UnitEnum) {
382+
return [
383+
'__class' => $data::class,
384+
'__enum' => $data instanceof BackedEnum ? $data->value : $data->name,
385+
];
386+
} else {
387+
$objectData = get_object_vars($data);
388+
389+
// Fallback for value objects with __toString()
390+
// when properties are not accessible
391+
if ($objectData === [] && method_exists($data, '__toString')) {
392+
return [
393+
'__class' => $data::class,
394+
'__string' => (string) $data,
395+
];
396+
}
397+
}
398+
399+
return [
400+
'__class' => $data::class,
401+
'__data' => $this->normalizeValue($objectData),
402+
];
403+
}
404+
405+
// Return scalars and null as-is
406+
return $data;
302407
}
303408

304409
/**

0 commit comments

Comments
 (0)