diff --git a/shared/lib/Json/JsonSerializableObject.php b/shared/lib/Json/JsonSerializableObject.php index b198dd0..ca0c7d8 100644 --- a/shared/lib/Json/JsonSerializableObject.php +++ b/shared/lib/Json/JsonSerializableObject.php @@ -77,7 +77,14 @@ abstract class JsonSerializableObject implements JsonSerializable, JsonDeseriali if (!empty($this->serializableProperties) && !in_array($key, $this->serializableProperties)) { continue; } - + + // Null means absent: leave the property at its default rather + // than passing null into an object's jsonDeserialize/enum + // coercion or overwriting an initialized collection. + if ($value === null) { + continue; + } + $type = gettype($this->$key); // Handle JsonDeserializable objects @@ -101,9 +108,10 @@ abstract class JsonSerializableObject implements JsonSerializable, JsonDeseriali $enumClass = get_class($this->$key); $this->$key = $enumClass::from($value); } - // Handle regular values + // Handle regular values, coercing by declared type when the + // current value is null and carries no instance to key off else { - $this->$key = $value; + $this->$key = $this->coerceValue($key, $value); } } } @@ -111,6 +119,48 @@ abstract class JsonSerializableObject implements JsonSerializable, JsonDeseriali return $this; } + /** + * Coerces a decoded JSON value to the property's declared type. Needed for + * properties whose current value is null: the instanceof-based branches + * above cannot dispatch without an instance, and assigning the raw string + * to a typed property (DateTime, enum, ...) would raise a TypeError. + */ + private function coerceValue(string $key, mixed $value): mixed { + $type = (new \ReflectionProperty($this, $key))->getType(); + if ($type instanceof \ReflectionNamedType) { + $names = [$type->getName()]; + } elseif ($type instanceof \ReflectionUnionType) { + $names = array_map(static fn($member) => $member->getName(), $type->getTypes()); + } else { + return $value; + } + + foreach ($names as $name) { + if (!class_exists($name) && !interface_exists($name) && !enum_exists($name)) { + continue; + } + if (is_string($value) && is_a($name, DateTimeInterface::class, true)) { + return $name === \DateTime::class ? new \DateTime($value) : new \DateTimeImmutable($value); + } + if (is_string($value) && is_a($name, DateTimeZone::class, true)) { + return new DateTimeZone($value); + } + if (is_string($value) && is_a($name, DateInterval::class, true)) { + return $this->toDateInterval($value); + } + if ((is_string($value) || is_int($value)) && is_subclass_of($name, \BackedEnum::class)) { + return $name::from($value); + } + if ((is_array($value) || is_string($value)) && is_subclass_of($name, JsonDeserializable::class)) { + /** @var JsonDeserializable $instance */ + $instance = new $name(); + return $instance->jsonDeserialize($value); + } + } + + return $value; + } + protected function fromDateInterval(DateInterval $interval): string { $spec = '';