1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Protocol\Execution;
6:
7: use Carbon\Carbon;
8: use LaravelUi5\OData\Edm\Contracts\Property\PropertyInterface;
9: use LaravelUi5\OData\Edm\Contracts\Type\EntityTypeInterface;
10: use LaravelUi5\OData\Edm\Contracts\Type\EnumTypeInterface;
11: use LaravelUi5\OData\Edm\Contracts\Type\PrimitiveTypeInterface;
12: use LaravelUi5\OData\Edm\EdmPrimitiveType;
13:
14: /**
15: * Coerces row values to OData v4 wire formats at JSON emission time.
16: *
17: * Two property kinds get coerced:
18: *
19: * Temporal primitives — MySQL `DATETIME`/`DATE`/`TIME` columns accessed via
20: * raw query builder yield strings like `2026-05-05 12:34:56` (no `T`, no
21: * offset), which is not a valid `Edm.DateTimeOffset` literal (RFC 3339
22: * §5.6 mandates `T` and a `Z`/numeric offset) and breaks `Date.parse()`
23: * in Safari. Coercion uses Carbon:
24: * - Edm.DateTimeOffset → Carbon::parse($v)->toRfc3339String()
25: * - Edm.Date → Carbon::parse($v)->toDateString() (Y-m-d)
26: * - Edm.TimeOfDay → Carbon::parse($v)->format('H:i:s')
27: * Already-correct strings round-trip cleanly.
28: *
29: * EnumType properties — backing-int values are projected to the symbolic
30: * member name on the wire (`tier: 1` → `tier: "Single"`), the short form
31: * that `sap.ui.model.odata.type.Enum` parses by default. Unknown ints
32: * pass through unchanged so schema drift is visible rather than masked.
33: */
34: final readonly class RowCoercion
35: {
36: /** @var array<string, callable(mixed): mixed> */
37: private array $coercers;
38:
39: public function __construct(EntityTypeInterface $type)
40: {
41: $this->coercers = self::buildCoercers($type);
42: }
43:
44: /**
45: * @param array<string, mixed> $row
46: * @return array<string, mixed>
47: */
48: public function apply(array $row): array
49: {
50: if ($this->coercers === []) {
51: return $row;
52: }
53:
54: foreach ($this->coercers as $name => $coercer) {
55: if (array_key_exists($name, $row) && $row[$name] !== null) {
56: $row[$name] = $coercer($row[$name]);
57: }
58: }
59:
60: return $row;
61: }
62:
63: /** @return array<string, callable(mixed): mixed> */
64: private static function buildCoercers(EntityTypeInterface $type): array
65: {
66: $coercers = [];
67:
68: foreach (self::collectProperties($type) as $property) {
69: if ($property->isCollection()) {
70: continue;
71: }
72:
73: $propertyType = $property->getType();
74:
75: if ($propertyType instanceof PrimitiveTypeInterface) {
76: $coercer = self::coercerForPrimitive($propertyType->getPrimitiveType());
77: if ($coercer !== null) {
78: $coercers[$property->getName()] = $coercer;
79: }
80: continue;
81: }
82:
83: if ($propertyType instanceof EnumTypeInterface) {
84: $coercers[$property->getName()] = self::coercerForEnum($propertyType);
85: }
86: }
87:
88: return $coercers;
89: }
90:
91: /** @return iterable<PropertyInterface> */
92: private static function collectProperties(EntityTypeInterface $type): iterable
93: {
94: $current = $type;
95: while ($current !== null) {
96: yield from $current->getDeclaredProperties();
97: $current = $current->getBaseType();
98: }
99: }
100:
101: private static function coercerForPrimitive(EdmPrimitiveType $type): ?callable
102: {
103: return match ($type) {
104: EdmPrimitiveType::DateTimeOffset => static fn (mixed $v): string => Carbon::parse($v)->toRfc3339String(),
105: EdmPrimitiveType::Date => static fn (mixed $v): string => Carbon::parse($v)->toDateString(),
106: EdmPrimitiveType::TimeOfDay => static fn (mixed $v): string => Carbon::parse($v)->format('H:i:s'),
107: default => null,
108: };
109: }
110:
111: /** @return callable(mixed): (string|int) */
112: private static function coercerForEnum(EnumTypeInterface $type): callable
113: {
114: $valueToName = [];
115: foreach ($type->getMembers() as $member) {
116: $valueToName[$member->getValue()] = $member->getName();
117: }
118:
119: return static function (mixed $v) use ($valueToName): string|int {
120: $intValue = is_int($v) ? $v : (int) $v;
121: return $valueToName[$intValue] ?? $intValue;
122: };
123: }
124: }
125: