Skip to content

Commit 3eceb0c

Browse files
committed
fix: Entity::toRawArray() converts DateTime objects to strings for non-recursive calls
1 parent 0c101bd commit 3eceb0c

6 files changed

Lines changed: 203 additions & 60 deletions

File tree

system/DataCaster/Cast/DatetimeCast.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use CodeIgniter\Database\BaseConnection;
1717
use CodeIgniter\Exceptions\InvalidArgumentException;
1818
use CodeIgniter\I18n\Time;
19+
use DateTimeInterface;
20+
use Exception;
1921

2022
/**
2123
* Class DatetimeCast
@@ -51,7 +53,15 @@ public static function set(
5153
array $params = [],
5254
?object $helper = null,
5355
): string {
54-
if (! $value instanceof Time) {
56+
if (is_string($value)) {
57+
try {
58+
$value = Time::parse($value);
59+
} catch (Exception) {
60+
self::invalidTypeValueError($value);
61+
}
62+
}
63+
64+
if (! $value instanceof DateTimeInterface) {
5565
self::invalidTypeValueError($value);
5666
}
5767

system/Entity/Entity.php

Lines changed: 66 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -261,84 +261,93 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false):
261261

262262
// When returning everything
263263
if (! $onlyChanged) {
264-
return $recursive
265-
? array_map($convert, $this->attributes)
266-
: $this->attributes;
267-
}
268-
269-
// When filtering by changed values only
270-
$return = [];
271-
272-
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;
264+
$result = array_map($convert, $this->attributes);
265+
} else {
266+
// When filtering by changed values only
267+
$result = [];
268+
269+
foreach ($this->attributes as $key => $value) {
270+
// Special handling for arrays of entities in recursive mode
271+
// Skip hasChanged() and do per-entity comparison directly
272+
if ($recursive && is_array($value) && $this->containsOnlyEntities($value)) {
273+
$originalValue = $this->original[$key] ?? null;
274+
275+
if (! is_string($originalValue)) {
276+
// No original or invalid format, export all entities
277+
$converted = [];
278+
279+
foreach ($value as $idx => $item) {
280+
$converted[$idx] = $item->toRawArray(false, true);
281+
}
282+
$result[$key] = $converted;
283+
284+
continue;
285+
}
277286

278-
if (! is_string($originalValue)) {
279-
// No original or invalid format, export all entities
280-
$converted = [];
287+
// Decode original array structure for per-entity comparison
288+
$originalArray = json_decode($originalValue, true);
289+
$converted = [];
281290

282291
foreach ($value as $idx => $item) {
283-
$converted[$idx] = $item->toRawArray(false, true);
292+
// Compare current entity against its original state
293+
$currentNormalized = $this->normalizeValue($item);
294+
$originalNormalized = $originalArray[$idx] ?? null;
295+
296+
// Only include if changed, new, or can't determine
297+
if ($originalNormalized === null || $currentNormalized !== $originalNormalized) {
298+
$converted[$idx] = $item->toRawArray(false, true);
299+
}
284300
}
285-
$return[$key] = $converted;
286301

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+
// Only include this property if at least one entity changed
303+
if ($converted !== []) {
304+
$result[$key] = $converted;
302305
}
303-
}
304306

305-
// Only include this property if at least one entity changed
306-
if ($converted !== []) {
307-
$return[$key] = $converted;
307+
continue;
308308
}
309309

310-
continue;
311-
}
310+
// For all other cases, use hasChanged()
311+
if (! $this->hasChanged($key)) {
312+
continue;
313+
}
312314

313-
// For all other cases, use hasChanged()
314-
if (! $this->hasChanged($key)) {
315-
continue;
316-
}
315+
if ($recursive) {
316+
// Special handling for arrays (mixed or not all entities)
317+
if (is_array($value)) {
318+
$converted = [];
317319

318-
if ($recursive) {
319-
// Special handling for arrays (mixed or not all entities)
320-
if (is_array($value)) {
321-
$converted = [];
320+
foreach ($value as $idx => $item) {
321+
$converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item);
322+
}
323+
$result[$key] = $converted;
322324

323-
foreach ($value as $idx => $item) {
324-
$converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item);
325+
continue;
325326
}
326-
$return[$key] = $converted;
327+
328+
// default recursive conversion
329+
$result[$key] = $convert($value);
327330

328331
continue;
329332
}
330333

331-
// default recursive conversion
332-
$return[$key] = $convert($value);
333-
334-
continue;
334+
// non-recursive changed value
335+
$result[$key] = $convert($value);
335336
}
337+
}
338+
339+
// Convert DateTime objects to string for user-facing calls
340+
if (! $recursive) {
341+
$result = array_map(static function ($value) {
342+
if ($value instanceof DateTimeInterface) {
343+
return method_exists($value, '__toString') ? (string) $value : $value->format('Y-m-d H:i:s');
344+
}
336345

337-
// non-recursive changed value
338-
$return[$key] = $value;
346+
return $value;
347+
}, $result);
339348
}
340349

341-
return $return;
350+
return $result;
342351
}
343352

344353
/**

tests/system/Entity/EntityTest.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,41 @@ public function testDateMutationTimeToTime(): void
367367
$this->assertCloseEnoughString($dt->format('Y-m-d H:i:s'), $time->format('Y-m-d H:i:s'));
368368
}
369369

370+
public function testToRawArrayConvertsDateTimeToString(): void
371+
{
372+
$entity = new class () extends Entity {
373+
protected $attributes = [
374+
'created_at' => null,
375+
'updated_at' => null,
376+
];
377+
protected $original = [
378+
'created_at' => null,
379+
'updated_at' => null,
380+
];
381+
};
382+
383+
$entity->created_at = '2023-12-12 12:12:12';
384+
$entity->updated_at = '2023-12-13 13:13:13';
385+
386+
$raw = $entity->toRawArray();
387+
388+
// toRawArray() should return primitive types, not objects
389+
$this->assertIsString($raw['created_at']);
390+
$this->assertSame('2023-12-12 12:12:12', $raw['created_at']);
391+
$this->assertIsString($raw['updated_at']);
392+
$this->assertSame('2023-12-13 13:13:13', $raw['updated_at']);
393+
394+
// Attributes themselves should still contain Time objects
395+
$attrs = $this->getPrivateProperty($entity, 'attributes');
396+
$this->assertInstanceOf(Time::class, $attrs['created_at']);
397+
$this->assertInstanceOf(Time::class, $attrs['updated_at']);
398+
399+
// toArray() should still return Time objects (no regression)
400+
$array = $entity->toArray();
401+
$this->assertInstanceOf(Time::class, $array['created_at']);
402+
$this->assertInstanceOf(Time::class, $array['updated_at']);
403+
}
404+
370405
public function testCastInteger(): void
371406
{
372407
$entity = $this->getCastEntity();
@@ -1421,6 +1456,93 @@ public function testToRawArrayOnlyChanged(): void
14211456
], $result);
14221457
}
14231458

1459+
public function testToRawArrayConvertsDateTimeToStringOnlyWhenNonRecursive(): void
1460+
{
1461+
$entity = $this->getEntity();
1462+
1463+
// Non-recursive: DateTime becomes string
1464+
$entity->created_at = '2024-03-15 10:30:00';
1465+
$raw = $entity->toRawArray();
1466+
$this->assertIsString($raw['created_at']);
1467+
1468+
// Recursive: DateTime stays Time
1469+
$recursive = $entity->toRawArray(false, true);
1470+
$this->assertInstanceOf(Time::class, $recursive['created_at']);
1471+
1472+
// Non-recursive onlyChanged: also converts to string
1473+
$entity->syncOriginal();
1474+
$entity->created_at = '2024-06-15 14:30:00';
1475+
$changed = $entity->toRawArray(true);
1476+
$this->assertIsString($changed['created_at']);
1477+
1478+
// Recursive onlyChanged: preserves Time
1479+
$changedRecursive = $entity->toRawArray(true, true);
1480+
$this->assertInstanceOf(Time::class, $changedRecursive['created_at']);
1481+
1482+
// Null values stay null regardless of mode
1483+
$entity2 = $this->getEntity();
1484+
$this->assertNull($entity2->toRawArray()['created_at']);
1485+
$this->assertNull($entity2->toRawArray(false, true)['created_at']);
1486+
1487+
// toArray() should not be affected (still returns Time)
1488+
$arr = $entity->toArray();
1489+
$this->assertInstanceOf(Time::class, $arr['createdAt']);
1490+
}
1491+
1492+
public function testToRawArrayRecursivePreservesTimeObjectsInNestedEntities(): void
1493+
{
1494+
$child = $this->getEntity();
1495+
$child->created_at = '2024-03-10 08:00:00';
1496+
1497+
$parent = $this->getEntity();
1498+
$parent->created_at = '2024-03-15 10:30:00';
1499+
$parent->entity = $child;
1500+
1501+
$result = $parent->toRawArray(false, true);
1502+
1503+
$this->assertInstanceOf(Time::class, $result['created_at']);
1504+
$this->assertIsArray($result['entity']);
1505+
$this->assertInstanceOf(Time::class, $result['entity']['created_at']);
1506+
1507+
// Non-recursive: converts to string
1508+
$nonRecursive = $parent->toRawArray();
1509+
$this->assertIsString($nonRecursive['created_at']);
1510+
1511+
// toArray() uses datamapped keys
1512+
$arr = $parent->toArray();
1513+
$this->assertInstanceOf(Time::class, $arr['createdAt']);
1514+
}
1515+
1516+
public function testToRawArrayRecursiveWithMixedAttributes(): void
1517+
{
1518+
$entity = new class () extends Entity {
1519+
protected $attributes = [
1520+
'name' => null,
1521+
'created_at' => null,
1522+
'count' => null,
1523+
];
1524+
protected $original = [
1525+
'name' => null,
1526+
'created_at' => null,
1527+
'count' => null,
1528+
];
1529+
};
1530+
1531+
$entity->name = 'test';
1532+
$entity->count = 42;
1533+
$entity->created_at = '2024-08-20 16:45:00';
1534+
1535+
// Recursive: scalars unchanged, DateTime preserved as Time
1536+
$result = $entity->toRawArray(false, true);
1537+
$this->assertSame('test', $result['name']);
1538+
$this->assertSame(42, $result['count']);
1539+
$this->assertInstanceOf(Time::class, $result['created_at']);
1540+
1541+
// Non-recursive: DateTime converted to string
1542+
$result2 = $entity->toRawArray();
1543+
$this->assertIsString($result2['created_at']);
1544+
}
1545+
14241546
public function testFilledConstruction(): void
14251547
{
14261548
$data = [

user_guide_src/source/changelogs/v4.7.5.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Deprecations
3030
Bugs Fixed
3131
**********
3232

33+
- **Entity:** Fixed a bug where ``toRawArray()`` returned ``Time`` objects instead of ISO-8601 strings for date fields.
34+
3335
- **Logger:** Fixed a bug where interpolating a log message with array or non-stringable context values could raise PHP warnings or errors.
3436

3537
See the repo's

utils/phpstan-baseline/empty.notAllowed.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# total 212 errors
1+
# total 205 errors
22

33
parameters:
44
ignoreErrors:

utils/phpstan-baseline/loader.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# total 1819 errors
1+
# total 1812 errors
22

33
includes:
44
- argument.type.neon

0 commit comments

Comments
 (0)