1313
1414namespace CodeIgniter \Entity ;
1515
16+ use BackedEnum ;
1617use CodeIgniter \DataCaster \DataCaster ;
1718use CodeIgniter \Entity \Cast \ArrayCast ;
1819use CodeIgniter \Entity \Cast \BooleanCast ;
3031use CodeIgniter \Entity \Exceptions \CastException ;
3132use CodeIgniter \I18n \Time ;
3233use DateTime ;
34+ use DateTimeInterface ;
3335use Exception ;
3436use JsonSerializable ;
3537use 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