Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion system/DataCaster/Cast/DatetimeCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\I18n\Time;
use DateTimeInterface;
use Exception;

/**
* Class DatetimeCast
Expand Down Expand Up @@ -51,7 +53,15 @@ public static function set(
array $params = [],
?object $helper = null,
): string {
if (! $value instanceof Time) {
if (is_string($value)) {
try {
$value = Time::parse($value);
} catch (Exception) {
self::invalidTypeValueError($value);
}
}

if (! $value instanceof DateTimeInterface) {
Comment on lines -54 to +64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now relative values such as "tomorrow" are accepted, which broadens the caster's behavior beyond this fix.

self::invalidTypeValueError($value);
}

Expand Down
12 changes: 11 additions & 1 deletion system/DataCaster/Cast/TimestampCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
namespace CodeIgniter\DataCaster\Cast;

use CodeIgniter\I18n\Time;
use DateTimeInterface;
use Exception;

/**
* Class TimestampCast
Expand All @@ -40,7 +42,15 @@ public static function set(
array $params = [],
?object $helper = null,
): int {
if (! $value instanceof Time) {
if (is_string($value)) {
try {
$value = Time::parse($value);
} catch (Exception) {
self::invalidTypeValueError($value);
}
}

if (! $value instanceof DateTimeInterface) {
self::invalidTypeValueError($value);
}

Expand Down
133 changes: 77 additions & 56 deletions system/Entity/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,84 +261,105 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false):

// 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;
$result = array_map($convert, $this->attributes);
} else {
// When filtering by changed values only
$result = [];

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);
}
$result[$key] = $converted;

continue;
}

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

foreach ($value as $idx => $item) {
$converted[$idx] = $item->toRawArray(false, true);
// 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 !== []) {
$result[$key] = $converted;
}
$return[$key] = $converted;

continue;
}

// Decode original array structure for per-entity comparison
$originalArray = json_decode($originalValue, true);
$converted = [];
// For all other cases, use hasChanged()
if (! $this->hasChanged($key)) {
continue;
}

foreach ($value as $idx => $item) {
// Compare current entity against its original state
$currentNormalized = $this->normalizeValue($item);
$originalNormalized = $originalArray[$idx] ?? null;
if ($recursive) {
// Special handling for arrays (mixed or not all entities)
if (is_array($value)) {
$converted = [];

// Only include if changed, new, or can't determine
if ($originalNormalized === null || $currentNormalized !== $originalNormalized) {
$converted[$idx] = $item->toRawArray(false, true);
foreach ($value as $idx => $item) {
$converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item);
}
$result[$key] = $converted;

continue;
}
}

// Only include this property if at least one entity changed
if ($converted !== []) {
$return[$key] = $converted;
}
// default recursive conversion
$result[$key] = $convert($value);

continue;
}
continue;
}

// For all other cases, use hasChanged()
if (! $this->hasChanged($key)) {
continue;
// non-recursive changed value
$result[$key] = $convert($value);
}
}

if ($recursive) {
// 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);
// Convert DateTime objects to string for user-facing calls (only for dates/datetime cast fields)
if (! $recursive) {
foreach ($result as $key => $value) {
if ($value instanceof DateTimeInterface) {
$isDate = in_array($key, $this->dates, true);
if (! $isDate && isset($this->casts[$key])) {
$cast = $this->casts[$key];
if (str_contains($cast, 'datetime')) {
$isDate = true;
}
}
$return[$key] = $converted;

continue;
if ($isDate) {
if ((int) $value->format('u') > 0) {
$result[$key] = $value->format('Y-m-d H:i:s.u');
} else {
$result[$key] = method_exists($value, '__toString') ? (string) $value : $value->format('Y-m-d H:i:s');
}
}
}

// default recursive conversion
$return[$key] = $convert($value);

continue;
}

// non-recursive changed value
$return[$key] = $value;
}

return $return;
return $result;
}

/**
Expand Down
39 changes: 39 additions & 0 deletions tests/system/DataConverter/DataConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use Closure;
use CodeIgniter\DataCaster\Exceptions\CastException;
use CodeIgniter\Entity\Entity;
use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time;
use CodeIgniter\Test\CIUnitTestCase;
Expand Down Expand Up @@ -877,6 +878,44 @@ public function testExtractWithClosure(): void
], $array);
}

public function testExtractEntityWithTimestampCast(): void
{
$converter = $this->createDataConverter([
'updated_at' => 'timestamp',
]);

$entity = new class () extends Entity {
protected $dates = [];
};
$entity->injectRawData([
'updated_at' => Time::createFromTimestamp(1_784_092_299),
]);

$data = $converter->extract($entity);

$this->assertSame(1_784_092_299, $data['updated_at']);
}

public function testExtractEntityPreservesMicrosecondsWithDatetimeCast(): void
{
$converter = $this->createDataConverter(
['updated_at' => 'datetime[us]'],
[],
db_connect(),
);

$entity = new class () extends Entity {
protected $dates = [];
};
$entity->injectRawData([
'updated_at' => Time::createFromFormat('Y-m-d H:i:s.u', '2026-07-15 00:00:01.123456'),
]);

$data = $converter->extract($entity);

$this->assertSame('2026-07-15 00:00:01.123456', $data['updated_at']);
}

/**
* @param array<string, string> $types
* @param array<string, mixed> $data
Expand Down
Loading
Loading