f9f824a2f0
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
215 lines
6.4 KiB
PHP
215 lines
6.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXF\Json;
|
|
|
|
use DateTimeInterface;
|
|
use DateTimeZone;
|
|
use DateInterval;
|
|
|
|
use KTXF\Json\JsonSerializable;
|
|
use KTXF\Json\JsonDeserializable;
|
|
|
|
abstract class JsonSerializableObject implements JsonSerializable, JsonDeserializable {
|
|
|
|
protected string $dateTimeFormat = 'c'; // ISO 8601 format by default
|
|
protected array $serializableProperties = []; // Empty array means serialize all properties
|
|
protected array $nonSerializableProperties = ['dateTimeFormat', 'serializableProperties', 'nonSerializableProperties']; // Properties to exclude from serialization
|
|
|
|
public function jsonSerialize(): mixed {
|
|
$vars = get_object_vars($this);
|
|
|
|
// Filter properties based on serializableProperties if specified
|
|
if (!empty($this->serializableProperties)) {
|
|
$vars = array_filter($vars, function($key) {
|
|
return in_array($key, $this->serializableProperties);
|
|
}, ARRAY_FILTER_USE_KEY);
|
|
}
|
|
|
|
// Process each property for special types
|
|
foreach ($vars as $key => $value) {
|
|
// Skip internal control properties
|
|
if (in_array($key, $this->nonSerializableProperties)) {
|
|
unset($vars[$key]);
|
|
continue;
|
|
}
|
|
|
|
// Handle DateTimeInterface (DateTime/DateTimeImmutable)
|
|
if ($value instanceof DateTimeInterface) {
|
|
$vars[$key] = $value->format($this->dateTimeFormat);
|
|
}
|
|
// Handle DateTimeZone
|
|
elseif ($value instanceof DateTimeZone) {
|
|
$vars[$key] = $value->getName();
|
|
}
|
|
// Handle DateInterval
|
|
elseif ($value instanceof DateInterval) {
|
|
$vars[$key] = $this->fromDateInterval($value);
|
|
}
|
|
// Handle backed enums
|
|
elseif ($value instanceof \BackedEnum) {
|
|
$vars[$key] = $value->value;
|
|
}
|
|
// Handle JsonSerializable objects
|
|
elseif ($value instanceof JsonSerializable) {
|
|
$vars[$key] = $value->jsonSerialize();
|
|
}
|
|
}
|
|
|
|
return $vars;
|
|
}
|
|
|
|
public function jsonDeserialize(array|string $data): static {
|
|
|
|
if (is_string($data)) {
|
|
$data = json_decode($data, true);
|
|
}
|
|
|
|
foreach ($data as $key => $value) {
|
|
if (property_exists($this, $key)) {
|
|
// Skip internal control properties
|
|
if (in_array($key, $this->nonSerializableProperties)) {
|
|
continue;
|
|
}
|
|
|
|
// Check if property should be deserialized (if serializableProperties is set)
|
|
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
|
|
if ($type === 'object' && $this->$key instanceof JsonDeserializable) {
|
|
$this->$key = $this->$key->jsonDeserialize($value);
|
|
}
|
|
// Handle DateTimeInterface (DateTime/DateTimeImmutable)
|
|
elseif ($type === 'object' && $this->$key instanceof DateTimeInterface) {
|
|
$this->$key = new \DateTimeImmutable($value);
|
|
}
|
|
// Handle DateTimeZone
|
|
elseif ($type === 'object' && $this->$key instanceof DateTimeZone) {
|
|
$this->$key = new DateTimeZone($value);
|
|
}
|
|
// Handle DateInterval
|
|
elseif ($type === 'object' && $this->$key instanceof DateInterval) {
|
|
$this->$key = $this->toDateInterval($value);
|
|
}
|
|
// Handle backed enums
|
|
elseif ($type === 'object' && $this->$key instanceof \BackedEnum) {
|
|
$enumClass = get_class($this->$key);
|
|
$this->$key = $enumClass::from($value);
|
|
}
|
|
// Handle regular values, coercing by declared type when the
|
|
// current value is null and carries no instance to key off
|
|
else {
|
|
$this->$key = $this->coerceValue($key, $value);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = '';
|
|
|
|
// Handle negative intervals
|
|
if ($interval->invert === 1) {
|
|
$spec = '-';
|
|
}
|
|
|
|
$spec .= 'P';
|
|
|
|
if ($interval->y > 0) $spec .= $interval->y . 'Y';
|
|
if ($interval->m > 0) $spec .= $interval->m . 'M';
|
|
if ($interval->d > 0) $spec .= $interval->d . 'D';
|
|
|
|
$timePart = '';
|
|
if ($interval->h > 0) $timePart .= $interval->h . 'H';
|
|
if ($interval->i > 0) $timePart .= $interval->i . 'M';
|
|
if ($interval->s > 0) $timePart .= $interval->s . 'S';
|
|
|
|
if (!empty($timePart)) {
|
|
$spec .= 'T' . $timePart;
|
|
}
|
|
|
|
// Handle edge case of zero duration
|
|
if ($spec === 'P' || $spec === '-P') {
|
|
$spec = 'PT0S';
|
|
}
|
|
|
|
return $spec;
|
|
}
|
|
|
|
protected function toDateInterval(string $value): DateInterval {
|
|
$isNegative = false;
|
|
|
|
// Check for negative interval
|
|
if (str_starts_with($value, '-')) {
|
|
$isNegative = true;
|
|
$value = substr($value, 1);
|
|
}
|
|
|
|
// Create the interval
|
|
$interval = new DateInterval($value);
|
|
|
|
// Set invert property for negative intervals
|
|
if ($isNegative) {
|
|
$interval->invert = 1;
|
|
}
|
|
|
|
return $interval;
|
|
}
|
|
|
|
} |