From 7d25bfbdc50605a37541e03f381188ae44cf03bd Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 20:03:07 +0100 Subject: [PATCH 01/46] Refactor mapper responsibilities into dedicated services --- src/JsonMapper.php | 562 +++++------------- .../Collection/CollectionFactory.php | 85 +++ src/JsonMapper/Context/MappingContext.php | 111 ++++ src/JsonMapper/Resolver/ClassResolver.php | 88 +++ src/JsonMapper/Type/TypeResolver.php | 47 ++ src/JsonMapper/Value/CustomTypeRegistry.php | 79 +++ .../BuiltinValueConversionStrategy.php | 37 ++ .../CollectionValueConversionStrategy.php | 43 ++ .../CustomTypeValueConversionStrategy.php | 40 ++ .../Strategy/NullValueConversionStrategy.php | 31 + .../ObjectValueConversionStrategy.php | 49 ++ .../PassthroughValueConversionStrategy.php | 31 + .../ValueConversionStrategyInterface.php | 25 + src/JsonMapper/Value/ValueConverter.php | 50 ++ .../JsonMapper/Context/MappingContextTest.php | 62 ++ .../JsonMapper/Resolver/ClassResolverTest.php | 57 ++ .../Value/CustomTypeRegistryTest.php | 51 ++ 17 files changed, 1036 insertions(+), 412 deletions(-) create mode 100644 src/JsonMapper/Collection/CollectionFactory.php create mode 100644 src/JsonMapper/Context/MappingContext.php create mode 100644 src/JsonMapper/Resolver/ClassResolver.php create mode 100644 src/JsonMapper/Type/TypeResolver.php create mode 100644 src/JsonMapper/Value/CustomTypeRegistry.php create mode 100644 src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php create mode 100644 src/JsonMapper/Value/ValueConverter.php create mode 100644 tests/JsonMapper/Context/MappingContextTest.php create mode 100644 tests/JsonMapper/Resolver/ClassResolverTest.php create mode 100644 tests/JsonMapper/Value/CustomTypeRegistryTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 600ee95..c39781b 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -14,11 +14,22 @@ use Closure; use Doctrine\Common\Annotations\Annotation; use Doctrine\Common\Annotations\AnnotationReader; -use DomainException; use InvalidArgumentException; use MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue; use MagicSunday\JsonMapper\Annotation\ReplaceProperty; +use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; +use MagicSunday\JsonMapper\Resolver\ClassResolver; +use MagicSunday\JsonMapper\Type\TypeResolver; +use MagicSunday\JsonMapper\Value\CustomTypeRegistry; +use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\CollectionValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\CustomTypeValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\NullValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; +use MagicSunday\JsonMapper\Value\ValueConverter; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -26,18 +37,21 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; -use Symfony\Component\TypeInfo\TypeIdentifier; use function array_key_exists; +use function call_user_func_array; +use function count; use function in_array; use function is_array; +use function is_callable; use function is_int; use function is_object; +use function is_string; +use function method_exists; +use function sprintf; +use function ucfirst; /** * JsonMapper. @@ -51,77 +65,59 @@ */ class JsonMapper { - /** - * @var PropertyInfoExtractorInterface - */ - private PropertyInfoExtractorInterface $extractor; + private TypeResolver $typeResolver; - /** - * @var PropertyAccessorInterface - */ - private PropertyAccessorInterface $accessor; + private ClassResolver $classResolver; - /** - * The property name converter instance. - * - * @var PropertyNameConverterInterface|null - */ - protected ?PropertyNameConverterInterface $nameConverter; + private ValueConverter $valueConverter; - /** - * Override class names that JsonMapper uses to create objects. Useful when your - * setter methods accept abstract classes or interfaces. - * - * @var string[]|Closure[] - */ - private array $classMap; + private CollectionFactory $collectionFactory; - /** - * The default value type instance. - * - * @var BuiltinType - */ - private BuiltinType $defaultType; + private CustomTypeRegistry $customTypeRegistry; - /** - * The custom types. - * - * @var Closure[] - */ - private array $types = []; - - /** - * JsonMapper constructor. - * - * @param PropertyInfoExtractorInterface $extractor - * @param PropertyAccessorInterface $accessor - * @param PropertyNameConverterInterface|null $nameConverter A name converter instance - * @param string[]|Closure[] $classMap A class map to override the class names - */ public function __construct( - PropertyInfoExtractorInterface $extractor, - PropertyAccessorInterface $accessor, - ?PropertyNameConverterInterface $nameConverter = null, + private readonly PropertyInfoExtractorInterface $extractor, + private readonly PropertyAccessorInterface $accessor, + private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ) { - $this->extractor = $extractor; - $this->accessor = $accessor; - $this->nameConverter = $nameConverter; - $this->defaultType = new BuiltinType(TypeIdentifier::STRING); - $this->classMap = $classMap; + $this->typeResolver = new TypeResolver($extractor); + $this->classResolver = new ClassResolver($classMap); + $this->customTypeRegistry = new CustomTypeRegistry(); + $this->valueConverter = new ValueConverter(); + $this->collectionFactory = new CollectionFactory( + $this->valueConverter, + $this->classResolver, + function (string $className, ?array $arguments): object { + if ($arguments === null) { + return $this->makeInstance($className, null); + } + + return $this->makeInstance($className, $arguments); + }, + ); + + $this->valueConverter->addStrategy(new NullValueConversionStrategy()); + $this->valueConverter->addStrategy(new CollectionValueConversionStrategy($this->collectionFactory)); + $this->valueConverter->addStrategy(new CustomTypeValueConversionStrategy($this->customTypeRegistry)); + $this->valueConverter->addStrategy( + new ObjectValueConversionStrategy( + $this->classResolver, + function (mixed $value, string $resolvedClass, MappingContext $context): mixed { + return $this->map($value, $resolvedClass, null, $context); + }, + ), + ); + $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); + $this->valueConverter->addStrategy(new PassthroughValueConversionStrategy()); } /** * Add a custom type. - * - * @param string $type The type name - * @param Closure $closure The closure to execute for the defined type - * - * @return JsonMapper */ public function addType(string $type, Closure $closure): JsonMapper { - $this->types[$type] = $closure; + $this->customTypeRegistry->register($type, $closure); return $this; } @@ -131,14 +127,11 @@ public function addType(string $type, Closure $closure): JsonMapper * * @template T * - * @param class-string $className The name of the base class - * @param Closure $closure The closure to execute if the base class was found - * - * @return JsonMapper + * @param class-string $className */ public function addCustomClassMapEntry(string $className, Closure $closure): JsonMapper { - $this->classMap[$className] = $closure; + $this->classResolver->add($className, $closure); return $this; } @@ -146,10 +139,9 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json The JSON to map - * @param class-string|null $className The class name of the initial element - * @param class-string|null $collectionClassName The class name of a collection used to assign - * the initial elements + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName * * @return mixed|TEntityCollection|TEntity|null * @@ -157,117 +149,106 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso * ? TEntityCollection * : ($className is class-string ? TEntity : null|mixed)) * - * @throws DomainException * @throws InvalidArgumentException */ - public function map(mixed $json, ?string $className = null, ?string $collectionClassName = null) - { - // Return plain JSON if no mapping classes are provided + public function map( + mixed $json, + ?string $className = null, + ?string $collectionClassName = null, + ?MappingContext $context = null, + ) { + $context ??= new MappingContext($json); + if ($className === null) { return $json; } - // Map the original given class names to a custom ones - $className = $this->getMappedClassName($className, $json); + $className = $this->classResolver->resolve($className, $json, $context); if ($collectionClassName !== null) { - $collectionClassName = $this->getMappedClassName($collectionClassName, $json); + $collectionClassName = $this->classResolver->resolve($collectionClassName, $json, $context); } - // Assert that the given classes exist $this->assertClassesExists($className, $collectionClassName); - // Handle collections if ($this->isIterableWithArraysOrObjects($json)) { - /** @var array|object $json */ if ($collectionClassName !== null) { - // Map arrays into collection class if given - return $this->makeInstance( - $collectionClassName, - $this->asCollection( - $json, - new ObjectType($className) - ) - ); + $collection = $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); + + return $this->makeInstance($collectionClassName, $collection); } - // Handle plain array collections if ($this->isNumericIndexArray($json)) { - // Map all elements of the JSON array to an array - return $this->asCollection( - $json, - new ObjectType($className) - ); + return $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); } } - $properties = $this->getProperties($className); - $entity = $this->makeInstance($className); + $entity = $this->makeInstance($className); - // Return entity if JSON is not an array or object (is_iterable won't work here) if (!is_array($json) && !is_object($json)) { return $entity; } - // Process all children + $properties = $this->getProperties($className); + $replacePropertyMap = $this->buildReplacePropertyMap($className); - /** @var string|int $propertyName */ foreach ($json as $propertyName => $propertyValue) { - // Replaces the property name with another one - if ($this->isReplacePropertyAnnotation($className)) { - $annotations = $this->extractClassAnnotations($className); - - foreach ($annotations as $annotation) { - if ( - ($annotation instanceof ReplaceProperty) - && ($propertyName === $annotation->replaces) - ) { - /** @var string $propertyName */ - $propertyName = $annotation->value; - } - } - } + $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); - if (is_string($propertyName) - && ($this->nameConverter instanceof PropertyNameConverterInterface) - ) { - $propertyName = $this->nameConverter->convert($propertyName); + if (!is_string($normalizedProperty)) { + continue; } - // Ignore all not defined properties - if (!in_array($propertyName, $properties, true)) { + if (!in_array($normalizedProperty, $properties, true)) { continue; } - $type = $this->getType($className, $propertyName); - $value = $this->getValue($propertyValue, $type); - - if ( - ($value === null) - && $this->isReplaceNullWithDefaultValueAnnotation($className, $propertyName) - ) { - // Get the default value of the property - $value = $this->getDefaultValue($className, $propertyName); - } + $context->withPathSegment($normalizedProperty, function (MappingContext $propertyContext) use ( + $className, + $normalizedProperty, + $propertyValue, + $entity, + ): void { + $type = $this->typeResolver->resolve($className, $normalizedProperty, $propertyContext); + $value = $this->convertValue($propertyValue, $type, $propertyContext); + + if ( + ($value === null) + && $this->isReplaceNullWithDefaultValueAnnotation($className, $normalizedProperty) + ) { + $value = $this->getDefaultValue($className, $normalizedProperty); + } - $this->setProperty($entity, $propertyName, $value); + $this->setProperty($entity, $normalizedProperty, $value); + }); } return $entity; } /** - * Creates an instance of the given class name. If a dependency injection container is provided, - * it returns the instance for this. + * Converts the provided JSON value using the registered strategies. + */ + private function convertValue(mixed $json, Type $type, MappingContext $context): mixed + { + if ($type instanceof CollectionType) { + return $this->collectionFactory->fromCollectionType($type, $json, $context); + } + + return $this->valueConverter->convert($json, $type, $context); + } + + /** + * Creates an instance of the given class name. * * @template T of object * - * @param class-string $className The class to instantiate - * @param array|null ...$constructorArguments The arguments of the constructor + * @param class-string $className + * @param mixed ...$constructorArguments * * @return T */ - private function makeInstance(string $className, ?array ...$constructorArguments) + private function makeInstance(string $className, mixed ...$constructorArguments) { /** @var T $instance */ $instance = new $className(...$constructorArguments); @@ -277,43 +258,56 @@ private function makeInstance(string $className, ?array ...$constructorArguments /** * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * - * @return bool */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { return $this->hasPropertyAnnotation( $className, $propertyName, - ReplaceNullWithDefaultValue::class + ReplaceNullWithDefaultValue::class, ); } /** - * Returns TRUE if the property contains an "ReplaceProperty" annotation. - * - * @param class-string $className The class name of the initial element + * Builds the map of properties replaced by the annotation. * - * @return bool + * @return array */ - private function isReplacePropertyAnnotation(string $className): bool + private function buildReplacePropertyMap(string $className): array { - return $this->hasClassAnnotation( - $className, - ReplaceProperty::class - ); + $map = []; + + foreach ($this->extractClassAnnotations($className) as $annotation) { + if (!($annotation instanceof ReplaceProperty)) { + continue; + } + + $map[$annotation->replaces] = $annotation->value; + } + + return $map; + } + + /** + * Normalizes the property name using annotations and converters. + */ + private function normalizePropertyName(string|int $propertyName, array $replacePropertyMap): string|int + { + $normalized = $propertyName; + + if (is_string($normalized) && array_key_exists($normalized, $replacePropertyMap)) { + $normalized = $replacePropertyMap[$normalized]; + } + + if (is_string($normalized) && ($this->nameConverter instanceof PropertyNameConverterInterface)) { + $normalized = $this->nameConverter->convert($normalized); + } + + return $normalized; } /** * Returns the specified reflection property. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * - * @return ReflectionProperty|null */ private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty { @@ -326,10 +320,6 @@ private function getReflectionProperty(string $className, string $propertyName): /** * Returns the specified reflection class. - * - * @param class-string $className The class name of the initial element - * - * @return ReflectionClass|null */ private function getReflectionClass(string $className): ?ReflectionClass { @@ -343,9 +333,6 @@ private function getReflectionClass(string $className): ?ReflectionClass /** * Extracts possible property annotations. * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * * @return Annotation[]|object[] */ private function extractPropertyAnnotations(string $className, string $propertyName): array @@ -363,8 +350,6 @@ private function extractPropertyAnnotations(string $className, string $propertyN /** * Extracts possible class annotations. * - * @param class-string $className The class name of the initial element - * * @return Annotation[]|object[] */ private function extractClassAnnotations(string $className): array @@ -381,12 +366,6 @@ private function extractClassAnnotations(string $className): array /** * Returns TRUE if the property has the given annotation. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * @param string $annotationName The name of the property annotation - * - * @return bool */ private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool { @@ -402,33 +381,7 @@ private function hasPropertyAnnotation(string $className, string $propertyName, } /** - * Returns TRUE if the class has the given annotation. - * - * @param class-string $className The class name of the initial element - * @param string $annotationName The name of the class annotation - * - * @return bool - */ - private function hasClassAnnotation(string $className, string $annotationName): bool - { - $annotations = $this->extractClassAnnotations($className); - - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - return true; - } - } - - return false; - } - - /** - * Extracts the default value of a property. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * - * @return mixed|null + * Returns the default value of a property. */ private function getDefaultValue(string $className, string $propertyName): mixed { @@ -443,10 +396,6 @@ private function getDefaultValue(string $className, string $propertyName): mixed /** * Returns TRUE if the given JSON contains integer property keys. - * - * @param array|object $json - * - * @return bool */ private function isNumericIndexArray(array|object $json): bool { @@ -461,14 +410,9 @@ private function isNumericIndexArray(array|object $json): bool /** * Returns TRUE if the given JSON is a plain array or object. - * - * @param mixed $json - * - * @return bool */ private function isIterableWithArraysOrObjects(mixed $json): bool { - // Return false if JSON is not an array or object (is_iterable won't work here) if (!is_array($json) && !is_object($json)) { return false; } @@ -490,12 +434,6 @@ private function isIterableWithArraysOrObjects(mixed $json): bool /** * Assert that the given classes exist. - * - * @param class-string $className The class name of the initial element - * @param class-string|null $collectionClassName The class name of a collection used to - * assign the initial elements - * - * @throws InvalidArgumentException */ private function assertClassesExists(string $className, ?string $collectionClassName = null): void { @@ -516,14 +454,9 @@ private function assertClassesExists(string $className, ?string $collectionClass /** * Sets a property value. - * - * @param object $entity - * @param string $name - * @param mixed $value */ private function setProperty(object $entity, string $name, mixed $value): void { - // Handle variadic setters if (is_array($value)) { $methodName = 'set' . ucfirst($name); @@ -549,205 +482,10 @@ private function setProperty(object $entity, string $name, mixed $value): void /** * Get all public properties for the specified class. * - * @param string $className The name of the class used to extract the properties - * * @return string[] */ private function getProperties(string $className): array { return $this->extractor->getProperties($className) ?? []; } - - /** - * Determine the type for the specified property using reflection. - * - * @param string $className The name of the class used to extract the property type info - * @param string $propertyName The name of the property - * - * @return Type - */ - private function getType(string $className, string $propertyName): Type - { - $extractedType = $this->extractor->getType($className, $propertyName) ?? $this->defaultType; - - if ($extractedType instanceof UnionType) { - return $extractedType->getTypes()[0]; - } - - return $extractedType; - } - - /** - * Get the value for the specified node. - * - * @param mixed $json - * @param Type $type - * - * @return mixed|null - * - * @throws DomainException - */ - private function getValue(mixed $json, Type $type): mixed - { - if ( - (is_array($json) || is_object($json)) - && ($type instanceof CollectionType) - ) { - $collectionType = $type->getCollectionValueType(); - $collection = $this->asCollection($json, $collectionType); - $wrappedType = $type->getWrappedType(); - - // Create a new instance of the collection class - if ( - ($wrappedType instanceof WrappingTypeInterface) - && ($wrappedType->getWrappedType() instanceof ObjectType) - ) { - return $this->makeInstance( - $this->getClassName($json, $wrappedType->getWrappedType()), - $collection - ); - } - - return $collection; - } - - // Ignore empty values - if ($json === null) { - return null; - } - - if ($type instanceof ObjectType) { - return $this->asObject($json, $type); - } - - if ($type instanceof BuiltinType) { - settype($json, $type->getTypeIdentifier()->value); - } - - return $json; - } - - /** - * Returns the mapped class name. - * - * @param class-string|string $className The class name to be mapped using the class map - * @param mixed $json The JSON data - * - * @return class-string - * - * @throws DomainException - */ - private function getMappedClassName(string $className, mixed $json): string - { - if (array_key_exists($className, $this->classMap)) { - $classNameOrClosure = $this->classMap[$className]; - - if (!($classNameOrClosure instanceof Closure)) { - /** @var class-string $classNameOrClosure */ - return $classNameOrClosure; - } - - // Execute closure to get the mapped class name - $className = $classNameOrClosure($json); - } - - /** @var class-string $className */ - return $className; - } - - /** - * Returns the class name. - * - * @param mixed $json - * @param ObjectType $type - * - * @return class-string - * - * @throws DomainException - */ - private function getClassName(mixed $json, ObjectType $type): string - { - return $this->getMappedClassName( - $type->getClassName(), - $json - ); - } - - /** - * Cast node to a collection. - * - * @param array|object|null $json - * @param Type $type - * - * @return mixed[]|null - * - * @throws DomainException - */ - private function asCollection(array|object|null $json, Type $type): ?array - { - if ($json === null) { - return null; - } - - $collection = []; - - foreach ($json as $key => $value) { - $collection[$key] = $this->getValue($value, $type); - } - - return $collection; - } - - /** - * Cast node to object. - * - * @param mixed $json - * @param ObjectType $type - * - * @return mixed|null - * - * @throws DomainException - */ - private function asObject(mixed $json, ObjectType $type): mixed - { - /** @var class-string $className */ - $className = $this->getClassName($json, $type); - - if ($this->isCustomType($className)) { - return $this->callCustomClosure($json, $className); - } - - return $this->map($json, $className); - } - - /** - * Determine if the specified type is a custom type. - * - * @template T - * - * @param class-string $typeClassName - * - * @return bool - */ - private function isCustomType(string $typeClassName): bool - { - return array_key_exists($typeClassName, $this->types); - } - - /** - * Call the custom closure for the specified type. - * - * @template T - * - * @param mixed $json - * @param class-string $typeClassName - * - * @return mixed - */ - private function callCustomClosure(mixed $json, string $typeClassName): mixed - { - $callback = $this->types[$typeClassName]; - - return $callback($json); - } } diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php new file mode 100644 index 0000000..e1a7a95 --- /dev/null +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -0,0 +1,85 @@ +|null):object $instantiator + */ + public function __construct( + private readonly ValueConverter $valueConverter, + private readonly ClassResolver $classResolver, + private readonly Closure $instantiator, + ) { + } + + /** + * Converts the provided iterable JSON structure to a PHP array. + */ + public function mapIterable(array|object|null $json, Type $valueType, MappingContext $context): ?array + { + if ($json === null) { + return null; + } + + if (!is_array($json) && !is_object($json)) { + return null; + } + + $collection = []; + + foreach ($json as $key => $value) { + $collection[$key] = $context->withPathSegment((string) $key, function (MappingContext $childContext) use ($valueType, $value): mixed { + return $this->valueConverter->convert($value, $valueType, $childContext); + }); + } + + return $collection; + } + + /** + * Builds a collection based on the specified collection type description. + */ + public function fromCollectionType(CollectionType $type, array|object|null $json, MappingContext $context): mixed + { + $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); + + $wrappedType = $type->getWrappedType(); + + if (($wrappedType instanceof WrappingTypeInterface) && ($wrappedType->getWrappedType() instanceof ObjectType)) { + $objectType = $wrappedType->getWrappedType(); + $className = $this->classResolver->resolve($objectType->getClassName(), $json, $context); + + $instantiator = $this->instantiator; + + return $instantiator($className, $collection); + } + + return $collection; + } +} diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php new file mode 100644 index 0000000..6927c4c --- /dev/null +++ b/src/JsonMapper/Context/MappingContext.php @@ -0,0 +1,111 @@ + + */ + private array $pathSegments; + + /** + * @var list + */ + private array $errors = []; + + /** + * @param mixed $rootInput The original JSON payload + * @param array $options Context options + */ + public function __construct( + private readonly mixed $rootInput, + private readonly array $options = [], + ) { + $this->pathSegments = []; + } + + /** + * Returns the root JSON input value. + */ + public function getRootInput(): mixed + { + return $this->rootInput; + } + + /** + * Returns the current path inside the JSON structure. + */ + public function getPath(): string + { + if ($this->pathSegments === []) { + return '$'; + } + + return '$.' . implode('.', $this->pathSegments); + } + + /** + * Executes the callback while appending the provided segment to the path. + * + * @param callable(self):mixed $callback + */ + public function withPathSegment(string|int $segment, callable $callback): mixed + { + $this->pathSegments[] = (string) $segment; + + try { + return $callback($this); + } finally { + array_pop($this->pathSegments); + } + } + + /** + * Stores the error message for later consumption. + */ + public function addError(string $message): void + { + $this->errors[] = $message; + } + + /** + * Returns collected mapping errors. + * + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Returns all options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Returns a single option by name. + */ + public function getOption(string $name, mixed $default = null): mixed + { + return $this->options[$name] ?? $default; + } +} diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php new file mode 100644 index 0000000..e38377b --- /dev/null +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -0,0 +1,88 @@ + $classMap + */ + public function __construct( + private array $classMap = [], + ) { + } + + /** + * Adds a custom resolution rule. + */ + public function add(string $className, Closure $resolver): void + { + $this->classMap[$className] = $resolver; + } + + /** + * Resolves the class name for the provided JSON payload. + * + * @param class-string $className + * + * @return class-string + */ + public function resolve(string $className, mixed $json, MappingContext $context): string + { + if (!array_key_exists($className, $this->classMap)) { + return $className; + } + + $mapped = $this->classMap[$className]; + + if (!($mapped instanceof Closure)) { + return $mapped; + } + + $resolved = $this->invokeResolver($mapped, $json, $context); + + if (!is_string($resolved)) { + throw new DomainException( + sprintf( + 'Class resolver for %s must return a class-string, %s given.', + $className, + get_debug_type($resolved), + ), + ); + } + + return $resolved; + } + + private function invokeResolver(Closure $resolver, mixed $json, MappingContext $context): mixed + { + $reflection = new ReflectionFunction($resolver); + + if ($reflection->getNumberOfParameters() >= 2) { + return $resolver($json, $context); + } + + return $resolver($json); + } +} diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php new file mode 100644 index 0000000..7c1b91a --- /dev/null +++ b/src/JsonMapper/Type/TypeResolver.php @@ -0,0 +1,47 @@ +defaultType = new BuiltinType(TypeIdentifier::STRING); + } + + /** + * Resolves the declared type for the provided property. + */ + public function resolve(string $className, string $propertyName, MappingContext $context): Type + { + $type = $this->extractor->getType($className, $propertyName); + + if ($type instanceof UnionType) { + return $type->getTypes()[0]; + } + + return $type ?? $this->defaultType; + } +} diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php new file mode 100644 index 0000000..0539c7b --- /dev/null +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -0,0 +1,79 @@ + + */ + private array $converters = []; + + /** + * Registers the converter for the provided class name. + */ + public function register(string $className, callable $converter): void + { + $this->converters[$className] = $this->normalizeConverter($converter); + } + + /** + * Returns TRUE if a converter for the class exists. + */ + public function has(string $className): bool + { + return array_key_exists($className, $this->converters); + } + + /** + * Executes the converter for the class. + */ + public function convert(string $className, mixed $value, MappingContext $context): mixed + { + return $this->converters[$className]($value, $context); + } + + /** + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + * + * @return callable(mixed, MappingContext):mixed + */ + private function normalizeConverter(callable $converter): callable + { + if ($converter instanceof Closure) { + $reflection = new ReflectionFunction($converter); + } elseif (is_array($converter)) { + $reflection = new ReflectionMethod($converter[0], $converter[1]); + } else { + $reflection = new ReflectionFunction(Closure::fromCallable($converter)); + } + + if ($reflection->getNumberOfParameters() >= 2) { + return $converter; + } + + return static function (mixed $value, MappingContext $context) use ($converter): mixed { + return $converter($value); + }; + } +} diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php new file mode 100644 index 0000000..15af92a --- /dev/null +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -0,0 +1,37 @@ +getTypeIdentifier()->value); + + return $converted; + } +} diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php new file mode 100644 index 0000000..c1537d2 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -0,0 +1,43 @@ +collectionFactory->fromCollectionType($type, $value, $context); + } +} diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php new file mode 100644 index 0000000..31db54f --- /dev/null +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -0,0 +1,40 @@ +registry->has($type->getClassName()); + } + + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + \assert($type instanceof ObjectType); + + return $this->registry->convert($type->getClassName(), $value, $context); + } +} diff --git a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php new file mode 100644 index 0000000..246ae73 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php @@ -0,0 +1,31 @@ +classResolver->resolve($type->getClassName(), $value, $context); + + $mapper = $this->mapper; + + return $mapper($value, $className, $context); + } +} diff --git a/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php new file mode 100644 index 0000000..74aba11 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php @@ -0,0 +1,31 @@ + + */ + private array $strategies = []; + + /** + * Registers the strategy at the end of the chain. + */ + public function addStrategy(ValueConversionStrategyInterface $strategy): void + { + $this->strategies[] = $strategy; + } + + /** + * Converts the value using the first matching strategy. + */ + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports($value, $type, $context)) { + return $strategy->convert($value, $type, $context); + } + } + + throw new LogicException(sprintf('No conversion strategy available for type %s.', $type::class)); + } +} diff --git a/tests/JsonMapper/Context/MappingContextTest.php b/tests/JsonMapper/Context/MappingContextTest.php new file mode 100644 index 0000000..dc8b5ce --- /dev/null +++ b/tests/JsonMapper/Context/MappingContextTest.php @@ -0,0 +1,62 @@ +getPath()); + + $result = $context->withPathSegment('items', function (MappingContext $child): string { + self::assertSame('$.items', $child->getPath()); + + $child->withPathSegment(0, function (MappingContext $nested): void { + self::assertSame('$.items.0', $nested->getPath()); + }); + + return 'done'; + }); + + self::assertSame('done', $result); + self::assertSame('$', $context->getPath()); + } + + #[Test] + public function itCollectsErrors(): void + { + $context = new MappingContext(['root']); + $context->addError('failure'); + + self::assertSame(['failure'], $context->getErrors()); + } + + #[Test] + public function itExposesOptions(): void + { + $context = new MappingContext(['root'], ['flag' => true]); + + self::assertSame(['flag' => true], $context->getOptions()); + self::assertTrue($context->getOption('flag')); + self::assertSame('fallback', $context->getOption('missing', 'fallback')); + } +} diff --git a/tests/JsonMapper/Resolver/ClassResolverTest.php b/tests/JsonMapper/Resolver/ClassResolverTest.php new file mode 100644 index 0000000..f2d0933 --- /dev/null +++ b/tests/JsonMapper/Resolver/ClassResolverTest.php @@ -0,0 +1,57 @@ + 'MappedClass']); + $context = new MappingContext([]); + + self::assertSame('MappedClass', $resolver->resolve('BaseClass', ['json'], $context)); + } + + #[Test] + public function itSupportsClosuresWithSingleArgument(): void + { + $resolver = new ClassResolver(['BaseClass' => static fn (): string => 'FromClosure']); + $context = new MappingContext([]); + + self::assertSame('FromClosure', $resolver->resolve('BaseClass', ['json'], $context)); + } + + #[Test] + public function itSupportsClosuresReceivingContext(): void + { + $resolver = new ClassResolver([ + 'BaseClass' => static function (array $json, MappingContext $context): string { + $context->addError('accessed'); + + return $json['next']; + }, + ]); + $context = new MappingContext([], ['flag' => true]); + + self::assertSame('ResolvedClass', $resolver->resolve('BaseClass', ['next' => 'ResolvedClass'], $context)); + self::assertSame(['accessed'], $context->getErrors()); + } +} diff --git a/tests/JsonMapper/Value/CustomTypeRegistryTest.php b/tests/JsonMapper/Value/CustomTypeRegistryTest.php new file mode 100644 index 0000000..22b57b1 --- /dev/null +++ b/tests/JsonMapper/Value/CustomTypeRegistryTest.php @@ -0,0 +1,51 @@ +register('Foo', static fn (array $value): array => $value); + + $context = new MappingContext([]); + + self::assertTrue($registry->has('Foo')); + self::assertSame(['bar' => 'baz'], $registry->convert('Foo', ['bar' => 'baz'], $context)); + } + + #[Test] + public function itPassesContextToConverters(): void + { + $registry = new CustomTypeRegistry(); + $registry->register('Foo', static function (array $value, MappingContext $context): array { + $context->addError('called'); + + return $value; + }); + + $context = new MappingContext([]); + $registry->convert('Foo', ['payload'], $context); + + self::assertSame(['called'], $context->getErrors()); + } +} From 37329f56d14f502316b47b2e24d581137f52cfa3 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 21:03:40 +0100 Subject: [PATCH 02/46] Run rector and align mapping type guards --- phpstan-baseline.neon | 13 +- src/JsonMapper.php | 174 +++++++++++------- .../Collection/CollectionFactory.php | 69 +++++-- src/JsonMapper/Context/MappingContext.php | 9 +- src/JsonMapper/Resolver/ClassResolver.php | 44 ++++- src/JsonMapper/Type/TypeResolver.php | 3 +- src/JsonMapper/Value/CustomTypeRegistry.php | 25 +-- .../BuiltinValueConversionStrategy.php | 4 +- .../CollectionValueConversionStrategy.php | 7 +- .../CustomTypeValueConversionStrategy.php | 8 +- .../ObjectValueConversionStrategy.php | 35 +++- tests/Fixtures/Resolver/DummyBaseClass.php | 16 ++ tests/Fixtures/Resolver/DummyMappedClass.php | 16 ++ .../Fixtures/Resolver/DummyResolvedClass.php | 16 ++ .../JsonMapper/Resolver/ClassResolverTest.php | 38 +++- .../Value/CustomTypeRegistryTest.php | 6 +- tests/TestCase.php | 6 +- 17 files changed, 341 insertions(+), 148 deletions(-) create mode 100644 tests/Fixtures/Resolver/DummyBaseClass.php create mode 100644 tests/Fixtures/Resolver/DummyMappedClass.php create mode 100644 tests/Fixtures/Resolver/DummyResolvedClass.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ff6c508..f51e71c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,13 +1,2 @@ parameters: - ignoreErrors: - - - message: '#^Argument of an invalid type array\\|object supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 2 - path: src/JsonMapper.php - - - - message: '#^Argument of an invalid type array\\|object supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 2 - path: src/JsonMapper.php + ignoreErrors: [] diff --git a/src/JsonMapper.php b/src/JsonMapper.php index c39781b..78d3701 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -39,16 +39,19 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Traversable; use function array_key_exists; use function call_user_func_array; use function count; +use function get_object_vars; use function in_array; use function is_array; use function is_callable; use function is_int; use function is_object; use function is_string; +use function iterator_to_array; use function method_exists; use function sprintf; use function ucfirst; @@ -59,9 +62,6 @@ * @author Rico Sonntag * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ - * - * @template TEntity - * @template TEntityCollection */ class JsonMapper { @@ -75,22 +75,27 @@ class JsonMapper private CustomTypeRegistry $customTypeRegistry; + /** + * @param array $classMap + * + * @phpstan-param array $classMap + */ public function __construct( private readonly PropertyInfoExtractorInterface $extractor, private readonly PropertyAccessorInterface $accessor, private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ) { - $this->typeResolver = new TypeResolver($extractor); - $this->classResolver = new ClassResolver($classMap); - $this->customTypeRegistry = new CustomTypeRegistry(); - $this->valueConverter = new ValueConverter(); - $this->collectionFactory = new CollectionFactory( + $this->typeResolver = new TypeResolver($extractor); + $this->classResolver = new ClassResolver($classMap); + $this->customTypeRegistry = new CustomTypeRegistry(); + $this->valueConverter = new ValueConverter(); + $this->collectionFactory = new CollectionFactory( $this->valueConverter, $this->classResolver, function (string $className, ?array $arguments): object { if ($arguments === null) { - return $this->makeInstance($className, null); + return $this->makeInstance($className); } return $this->makeInstance($className, $arguments); @@ -103,9 +108,7 @@ function (string $className, ?array $arguments): object { $this->valueConverter->addStrategy( new ObjectValueConversionStrategy( $this->classResolver, - function (mixed $value, string $resolvedClass, MappingContext $context): mixed { - return $this->map($value, $resolvedClass, null, $context); - }, + fn (mixed $value, string $resolvedClass, MappingContext $context): mixed => $this->map($value, $resolvedClass, null, $context), ), ); $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); @@ -125,9 +128,11 @@ public function addType(string $type, Closure $closure): JsonMapper /** * Add a custom class map entry. * - * @template T + * @param class-string $className + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure * - * @param class-string $className + * @phpstan-param class-string $className + * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure */ public function addCustomClassMapEntry(string $className, Closure $closure): JsonMapper { @@ -139,15 +144,9 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName - * - * @return mixed|TEntityCollection|TEntity|null - * - * @phpstan-return ($collectionClassName is class-string - * ? TEntityCollection - * : ($className is class-string ? TEntity : null|mixed)) + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName * * @throws InvalidArgumentException */ @@ -156,43 +155,50 @@ public function map( ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, - ) { + ): mixed { $context ??= new MappingContext($json); if ($className === null) { return $json; } - $className = $this->classResolver->resolve($className, $json, $context); + /** @var class-string $resolvedClassName */ + $resolvedClassName = $this->classResolver->resolve($className, $json, $context); - if ($collectionClassName !== null) { - $collectionClassName = $this->classResolver->resolve($collectionClassName, $json, $context); - } + /** @var class-string|null $resolvedCollectionClassName */ + $resolvedCollectionClassName = $collectionClassName === null + ? null + : $this->classResolver->resolve($collectionClassName, $json, $context); - $this->assertClassesExists($className, $collectionClassName); + $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); - if ($this->isIterableWithArraysOrObjects($json)) { - if ($collectionClassName !== null) { - $collection = $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); + if (!is_array($json) && !is_object($json)) { + return $this->makeInstance($resolvedClassName); + } - return $this->makeInstance($collectionClassName, $collection); - } + if ( + ($resolvedCollectionClassName !== null) + && $this->isIterableWithArraysOrObjects($json) + ) { + $collection = $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); - if ($this->isNumericIndexArray($json)) { - return $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); - } + return $this->makeInstance($resolvedCollectionClassName, $collection); } - $entity = $this->makeInstance($className); - - if (!is_array($json) && !is_object($json)) { - return $entity; + if ( + $this->isIterableWithArraysOrObjects($json) + && $this->isNumericIndexArray($json) + ) { + return $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); } - $properties = $this->getProperties($className); - $replacePropertyMap = $this->buildReplacePropertyMap($className); + $entity = $this->makeInstance($resolvedClassName); + $source = $this->toIterableArray($json); + + $properties = $this->getProperties($resolvedClassName); + $replacePropertyMap = $this->buildReplacePropertyMap($resolvedClassName); - foreach ($json as $propertyName => $propertyValue) { + foreach ($source as $propertyName => $propertyValue) { $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); if (!is_string($normalizedProperty)) { @@ -204,19 +210,19 @@ public function map( } $context->withPathSegment($normalizedProperty, function (MappingContext $propertyContext) use ( - $className, + $resolvedClassName, $normalizedProperty, $propertyValue, $entity, ): void { - $type = $this->typeResolver->resolve($className, $normalizedProperty, $propertyContext); + $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); $value = $this->convertValue($propertyValue, $type, $propertyContext); if ( ($value === null) - && $this->isReplaceNullWithDefaultValueAnnotation($className, $normalizedProperty) + && $this->isReplaceNullWithDefaultValueAnnotation($resolvedClassName, $normalizedProperty) ) { - $value = $this->getDefaultValue($className, $normalizedProperty); + $value = $this->getDefaultValue($resolvedClassName, $normalizedProperty); } $this->setProperty($entity, $normalizedProperty, $value); @@ -241,24 +247,21 @@ private function convertValue(mixed $json, Type $type, MappingContext $context): /** * Creates an instance of the given class name. * - * @template T of object - * - * @param class-string $className - * @param mixed ...$constructorArguments - * - * @return T + * @param string $className */ - private function makeInstance(string $className, mixed ...$constructorArguments) + private function makeInstance(string $className, mixed ...$constructorArguments): object { - /** @var T $instance */ - $instance = new $className(...$constructorArguments); - - return $instance; + return new $className(...$constructorArguments); } /** * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. */ + /** + * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. + * + * @param class-string $className + */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { return $this->hasPropertyAnnotation( @@ -271,6 +274,8 @@ private function isReplaceNullWithDefaultValueAnnotation(string $className, stri /** * Builds the map of properties replaced by the annotation. * + * @param class-string $className + * * @return array */ private function buildReplacePropertyMap(string $className): array @@ -282,6 +287,10 @@ private function buildReplacePropertyMap(string $className): array continue; } + if (!is_string($annotation->value)) { + continue; + } + $map[$annotation->replaces] = $annotation->value; } @@ -290,6 +299,8 @@ private function buildReplacePropertyMap(string $className): array /** * Normalizes the property name using annotations and converters. + * + * @param array $replacePropertyMap */ private function normalizePropertyName(string|int $propertyName, array $replacePropertyMap): string|int { @@ -300,14 +311,36 @@ private function normalizePropertyName(string|int $propertyName, array $replaceP } if (is_string($normalized) && ($this->nameConverter instanceof PropertyNameConverterInterface)) { - $normalized = $this->nameConverter->convert($normalized); + return $this->nameConverter->convert($normalized); } return $normalized; } + /** + * Converts arrays and objects into a plain array structure. + * + * @param array|object $json + * + * @return array + */ + private function toIterableArray(array|object $json): array + { + if ($json instanceof Traversable) { + return iterator_to_array($json); + } + + if (is_object($json)) { + return get_object_vars($json); + } + + return $json; + } + /** * Returns the specified reflection property. + * + * @param class-string $className */ private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty { @@ -320,6 +353,8 @@ private function getReflectionProperty(string $className, string $propertyName): /** * Returns the specified reflection class. + * + * @param class-string $className */ private function getReflectionClass(string $className): ?ReflectionClass { @@ -333,6 +368,8 @@ private function getReflectionClass(string $className): ?ReflectionClass /** * Extracts possible property annotations. * + * @param class-string $className + * * @return Annotation[]|object[] */ private function extractPropertyAnnotations(string $className, string $propertyName): array @@ -350,6 +387,8 @@ private function extractPropertyAnnotations(string $className, string $propertyN /** * Extracts possible class annotations. * + * @param class-string $className + * * @return Annotation[]|object[] */ private function extractClassAnnotations(string $className): array @@ -366,6 +405,9 @@ private function extractClassAnnotations(string $className): array /** * Returns TRUE if the property has the given annotation. + * + * @param class-string $className + * @param class-string $annotationName */ private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool { @@ -382,6 +424,8 @@ private function hasPropertyAnnotation(string $className, string $propertyName, /** * Returns the default value of a property. + * + * @param class-string $className */ private function getDefaultValue(string $className, string $propertyName): mixed { @@ -396,10 +440,12 @@ private function getDefaultValue(string $className, string $propertyName): mixed /** * Returns TRUE if the given JSON contains integer property keys. + * + * @param array|object $json */ private function isNumericIndexArray(array|object $json): bool { - foreach ($json as $propertyName => $propertyValue) { + foreach (array_keys($this->toIterableArray($json)) as $propertyName) { if (is_int($propertyName)) { return true; } @@ -417,7 +463,9 @@ private function isIterableWithArraysOrObjects(mixed $json): bool return false; } - foreach ($json as $propertyValue) { + $values = is_array($json) ? $json : $this->toIterableArray($json); + + foreach ($values as $propertyValue) { if (is_array($propertyValue)) { continue; } @@ -482,6 +530,8 @@ private function setProperty(object $entity, string $name, mixed $value): void /** * Get all public properties for the specified class. * + * @param class-string $className + * * @return string[] */ private function getProperties(string $className): array diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index e1a7a95..db14ccd 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -12,6 +12,7 @@ namespace MagicSunday\JsonMapper\Collection; use Closure; +use DomainException; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Value\ValueConverter; @@ -19,27 +20,34 @@ use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Traversable; +use function get_object_vars; use function is_array; use function is_object; +use function iterator_to_array; /** * Creates collections and hydrates wrapping collection classes. */ -final class CollectionFactory +final readonly class CollectionFactory { /** - * @param Closure(class-string, array|null):object $instantiator + * @param Closure(class-string, array|null):object $instantiator */ public function __construct( - private readonly ValueConverter $valueConverter, - private readonly ClassResolver $classResolver, - private readonly Closure $instantiator, + private ValueConverter $valueConverter, + private ClassResolver $classResolver, + private Closure $instantiator, ) { } /** * Converts the provided iterable JSON structure to a PHP array. + * + * @param array|object|null $json + * + * @return array|null */ public function mapIterable(array|object|null $json, Type $valueType, MappingContext $context): ?array { @@ -47,16 +55,17 @@ public function mapIterable(array|object|null $json, Type $valueType, MappingCon return null; } - if (!is_array($json) && !is_object($json)) { - return null; - } + /** @var array $source */ + $source = match (true) { + $json instanceof Traversable => iterator_to_array($json), + is_object($json) => get_object_vars($json), + default => $json, + }; $collection = []; - foreach ($json as $key => $value) { - $collection[$key] = $context->withPathSegment((string) $key, function (MappingContext $childContext) use ($valueType, $value): mixed { - return $this->valueConverter->convert($value, $valueType, $childContext); - }); + foreach ($source as $key => $value) { + $collection[$key] = $context->withPathSegment((string) $key, fn (MappingContext $childContext): mixed => $this->valueConverter->convert($value, $valueType, $childContext)); } return $collection; @@ -64,22 +73,48 @@ public function mapIterable(array|object|null $json, Type $valueType, MappingCon /** * Builds a collection based on the specified collection type description. + * + * @return array|object|null */ - public function fromCollectionType(CollectionType $type, array|object|null $json, MappingContext $context): mixed + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed { - $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); + $collection = $this->mapIterable( + is_array($json) || is_object($json) ? $json : null, + $type->getCollectionValueType(), + $context, + ); $wrappedType = $type->getWrappedType(); if (($wrappedType instanceof WrappingTypeInterface) && ($wrappedType->getWrappedType() instanceof ObjectType)) { - $objectType = $wrappedType->getWrappedType(); - $className = $this->classResolver->resolve($objectType->getClassName(), $json, $context); + $objectType = $wrappedType->getWrappedType(); + $className = $this->resolveWrappedClass($objectType); + $resolvedClass = $this->classResolver->resolve($className, $json, $context); $instantiator = $this->instantiator; - return $instantiator($className, $collection); + return $instantiator($resolvedClass, $collection); } return $collection; } + + /** + * Resolves the wrapped collection class name. + * + * @return class-string + * + * @throws DomainException + */ + private function resolveWrappedClass(ObjectType $objectType): string + { + $className = $objectType->getClassName(); + + if ($className === '') { + throw new DomainException('Collection type must define a class-string for the wrapped object.'); + } + + /** @var class-string $className */ + return $className; + } } diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 6927c4c..afcf61a 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -19,7 +19,7 @@ final class MappingContext /** * @var list */ - private array $pathSegments; + private array $pathSegments = []; /** * @var list @@ -30,11 +30,8 @@ final class MappingContext * @param mixed $rootInput The original JSON payload * @param array $options Context options */ - public function __construct( - private readonly mixed $rootInput, - private readonly array $options = [], - ) { - $this->pathSegments = []; + public function __construct(private readonly mixed $rootInput, private readonly array $options = []) + { } /** diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index e38377b..3af1364 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -17,8 +17,11 @@ use ReflectionFunction; use function array_key_exists; +use function class_exists; use function get_debug_type; +use function interface_exists; use function is_string; +use function sprintf; /** * Resolves class names using the configured class map. @@ -26,7 +29,9 @@ final class ClassResolver { /** - * @param array $classMap + * @param array $classMap + * + * @phpstan-param array $classMap */ public function __construct( private array $classMap = [], @@ -35,6 +40,12 @@ public function __construct( /** * Adds a custom resolution rule. + * + * @param class-string $className + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver + * + * @phpstan-param class-string $className + * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver */ public function add(string $className, Closure $resolver): void { @@ -45,19 +56,22 @@ public function add(string $className, Closure $resolver): void * Resolves the class name for the provided JSON payload. * * @param class-string $className + * @param mixed $json * * @return class-string + * + * @throws DomainException */ public function resolve(string $className, mixed $json, MappingContext $context): string { if (!array_key_exists($className, $this->classMap)) { - return $className; + return $this->assertClassString($className); } $mapped = $this->classMap[$className]; if (!($mapped instanceof Closure)) { - return $mapped; + return $this->assertClassString($mapped); } $resolved = $this->invokeResolver($mapped, $json, $context); @@ -72,9 +86,12 @@ public function resolve(string $className, mixed $json, MappingContext $context) ); } - return $resolved; + return $this->assertClassString($resolved); } + /** + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver + */ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $context): mixed { $reflection = new ReflectionFunction($resolver); @@ -85,4 +102,23 @@ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $ return $resolver($json); } + + /** + * @return class-string + * + * @throws DomainException + */ + private function assertClassString(string $className): string + { + if ($className === '') { + throw new DomainException('Resolved class name must not be empty.'); + } + + if (!class_exists($className) && !interface_exists($className)) { + throw new DomainException(sprintf('Resolved class %s does not exist.', $className)); + } + + /** @var class-string $className */ + return $className; + } } diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 7c1b91a..607553a 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -11,7 +11,6 @@ namespace MagicSunday\JsonMapper\Type; -use MagicSunday\JsonMapper\Context\MappingContext; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -34,7 +33,7 @@ public function __construct( /** * Resolves the declared type for the provided property. */ - public function resolve(string $className, string $propertyName, MappingContext $context): Type + public function resolve(string $className, string $propertyName): Type { $type = $this->extractor->getType($className, $propertyName); diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index 0539c7b..84a6174 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -14,10 +14,8 @@ use Closure; use MagicSunday\JsonMapper\Context\MappingContext; use ReflectionFunction; -use ReflectionMethod; use function array_key_exists; -use function is_array; /** * Stores custom conversion handlers keyed by class name. @@ -25,12 +23,14 @@ final class CustomTypeRegistry { /** - * @var array + * @var array */ private array $converters = []; /** * Registers the converter for the provided class name. + * + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter */ public function register(string $className, callable $converter): void { @@ -55,25 +55,16 @@ public function convert(string $className, mixed $value, MappingContext $context /** * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter - * - * @return callable(mixed, MappingContext):mixed */ - private function normalizeConverter(callable $converter): callable + private function normalizeConverter(callable $converter): Closure { - if ($converter instanceof Closure) { - $reflection = new ReflectionFunction($converter); - } elseif (is_array($converter)) { - $reflection = new ReflectionMethod($converter[0], $converter[1]); - } else { - $reflection = new ReflectionFunction(Closure::fromCallable($converter)); - } + $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); + $reflection = new ReflectionFunction($closure); if ($reflection->getNumberOfParameters() >= 2) { - return $converter; + return $closure; } - return static function (mixed $value, MappingContext $context) use ($converter): mixed { - return $converter($value); - }; + return static fn (mixed $value, MappingContext $context): mixed => $closure($value); } } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index 15af92a..c718c53 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -15,6 +15,8 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; +use function assert; + /** * Converts scalar values to the requested builtin type. */ @@ -27,7 +29,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof BuiltinType); + assert($type instanceof BuiltinType); $converted = $value; settype($converted, $type->getTypeIdentifier()->value); diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index c1537d2..44dc1b3 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -16,16 +16,17 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; +use function assert; use function is_array; use function is_object; /** * Converts collection values using the configured factory. */ -final class CollectionValueConversionStrategy implements ValueConversionStrategyInterface +final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface { public function __construct( - private readonly CollectionFactory $collectionFactory, + private CollectionFactory $collectionFactory, ) { } @@ -36,7 +37,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof CollectionType); + assert($type instanceof CollectionType); return $this->collectionFactory->fromCollectionType($type, $value, $context); } diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php index 31db54f..5a9c5fe 100644 --- a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -16,13 +16,15 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; +use function assert; + /** * Handles conversion of registered custom types. */ -final class CustomTypeValueConversionStrategy implements ValueConversionStrategyInterface +final readonly class CustomTypeValueConversionStrategy implements ValueConversionStrategyInterface { public function __construct( - private readonly CustomTypeRegistry $registry, + private CustomTypeRegistry $registry, ) { } @@ -33,7 +35,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof ObjectType); + assert($type instanceof ObjectType); return $this->registry->convert($type->getClassName(), $value, $context); } diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 7153804..3f955d0 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -12,6 +12,7 @@ namespace MagicSunday\JsonMapper\Value\Strategy; use Closure; +use LogicException; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Resolver\ClassResolver; use Symfony\Component\TypeInfo\Type; @@ -20,14 +21,14 @@ /** * Converts object values by delegating to the mapper callback. */ -final class ObjectValueConversionStrategy implements ValueConversionStrategyInterface +final readonly class ObjectValueConversionStrategy implements ValueConversionStrategyInterface { /** - * @param callable(mixed, class-string, MappingContext):mixed $mapper + * @param Closure(mixed, class-string, MappingContext):mixed $mapper */ public function __construct( - private readonly ClassResolver $classResolver, - private readonly Closure $mapper, + private ClassResolver $classResolver, + private Closure $mapper, ) { } @@ -38,12 +39,32 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof ObjectType); + if (!($type instanceof ObjectType)) { + throw new LogicException('ObjectValueConversionStrategy requires an object type.'); + } - $className = $this->classResolver->resolve($type->getClassName(), $value, $context); + $className = $this->resolveClassName($type); + $resolvedClass = $this->classResolver->resolve($className, $value, $context); $mapper = $this->mapper; - return $mapper($value, $className, $context); + return $mapper($value, $resolvedClass, $context); + } + + /** + * Resolves the class name from the provided object type. + * + * @return class-string + */ + private function resolveClassName(ObjectType $type): string + { + $className = $type->getClassName(); + + if ($className === '') { + throw new LogicException('Object type must define a class-string.'); + } + + /** @var class-string $className */ + return $className; } } diff --git a/tests/Fixtures/Resolver/DummyBaseClass.php b/tests/Fixtures/Resolver/DummyBaseClass.php new file mode 100644 index 0000000..c0bea12 --- /dev/null +++ b/tests/Fixtures/Resolver/DummyBaseClass.php @@ -0,0 +1,16 @@ + 'MappedClass']); + $resolver = new ClassResolver([DummyBaseClass::class => DummyMappedClass::class]); $context = new MappingContext([]); - self::assertSame('MappedClass', $resolver->resolve('BaseClass', ['json'], $context)); + self::assertSame(DummyMappedClass::class, $resolver->resolve(DummyBaseClass::class, ['json'], $context)); } #[Test] public function itSupportsClosuresWithSingleArgument(): void { - $resolver = new ClassResolver(['BaseClass' => static fn (): string => 'FromClosure']); + $resolver = new ClassResolver([DummyBaseClass::class => static fn (): string => DummyMappedClass::class]); $context = new MappingContext([]); - self::assertSame('FromClosure', $resolver->resolve('BaseClass', ['json'], $context)); + self::assertSame(DummyMappedClass::class, $resolver->resolve(DummyBaseClass::class, ['json'], $context)); } #[Test] public function itSupportsClosuresReceivingContext(): void { $resolver = new ClassResolver([ - 'BaseClass' => static function (array $json, MappingContext $context): string { + DummyBaseClass::class => static function (mixed $json, MappingContext $context): string { $context->addError('accessed'); - return $json['next']; + return DummyResolvedClass::class; }, ]); $context = new MappingContext([], ['flag' => true]); - self::assertSame('ResolvedClass', $resolver->resolve('BaseClass', ['next' => 'ResolvedClass'], $context)); + self::assertSame(DummyResolvedClass::class, $resolver->resolve(DummyBaseClass::class, ['payload'], $context)); self::assertSame(['accessed'], $context->getErrors()); } + + #[Test] + public function itRejectsResolversReturningNonStrings(): void + { + $resolver = new ClassResolver(); + + $classMap = new ReflectionProperty(ClassResolver::class, 'classMap'); + $classMap->setAccessible(true); + $classMap->setValue($resolver, [ + DummyBaseClass::class => static fn (): int => 123, + ]); + + $context = new MappingContext([]); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Class resolver for ' . DummyBaseClass::class . ' must return a class-string, int given.'); + + $resolver->resolve(DummyBaseClass::class, ['json'], $context); + } } diff --git a/tests/JsonMapper/Value/CustomTypeRegistryTest.php b/tests/JsonMapper/Value/CustomTypeRegistryTest.php index 22b57b1..a9de3f3 100644 --- a/tests/JsonMapper/Value/CustomTypeRegistryTest.php +++ b/tests/JsonMapper/Value/CustomTypeRegistryTest.php @@ -25,7 +25,7 @@ final class CustomTypeRegistryTest extends TestCase public function itNormalizesSingleArgumentClosures(): void { $registry = new CustomTypeRegistry(); - $registry->register('Foo', static fn (array $value): array => $value); + $registry->register('Foo', static fn (mixed $value): array => (array) $value); $context = new MappingContext([]); @@ -37,10 +37,10 @@ public function itNormalizesSingleArgumentClosures(): void public function itPassesContextToConverters(): void { $registry = new CustomTypeRegistry(); - $registry->register('Foo', static function (array $value, MappingContext $context): array { + $registry->register('Foo', static function (mixed $value, MappingContext $context): array { $context->addError('called'); - return $value; + return (array) $value; }); $context = new MappingContext([]); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7a91fa9..960db0d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -32,9 +32,7 @@ class TestCase extends \PHPUnit\Framework\TestCase /** * Returns an instance of the JsonMapper for testing. * - * @param string[]|Closure[] $classMap - * - * @return JsonMapper + * @param array $classMap */ protected function getJsonMapper(array $classMap = []): JsonMapper { @@ -46,7 +44,7 @@ protected function getJsonMapper(array $classMap = []): JsonMapper $extractor, PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), - $classMap + $classMap, ); } From 5b8f122d291d5f9ba5ba5e72044799e554fd5fbc Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 21:25:05 +0100 Subject: [PATCH 03/46] Add PSR-6 caching for type resolution --- src/JsonMapper.php | 5 +- src/JsonMapper/Resolver/ClassResolver.php | 36 ++- src/JsonMapper/Type/TypeResolver.php | 88 ++++++- tests/JsonMapper/Type/TypeResolverTest.php | 263 +++++++++++++++++++++ 4 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 tests/JsonMapper/Type/TypeResolverTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 78d3701..e2afea8 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -30,6 +30,7 @@ use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; use MagicSunday\JsonMapper\Value\ValueConverter; +use Psr\Cache\CacheItemPoolInterface; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -77,6 +78,7 @@ class JsonMapper /** * @param array $classMap + * @param CacheItemPoolInterface|null $typeCache * * @phpstan-param array $classMap */ @@ -85,8 +87,9 @@ public function __construct( private readonly PropertyAccessorInterface $accessor, private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], + ?CacheItemPoolInterface $typeCache = null, ) { - $this->typeResolver = new TypeResolver($extractor); + $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); $this->customTypeRegistry = new CustomTypeRegistry(); $this->valueConverter = new ValueConverter(); diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index 3af1364..c5d4c18 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -28,14 +28,21 @@ */ final class ClassResolver { + /** + * @var array + * + * @phpstan-var array + */ + private array $classMap; + /** * @param array $classMap * * @phpstan-param array $classMap */ - public function __construct( - private array $classMap = [], - ) { + public function __construct(array $classMap = []) + { + $this->classMap = $this->validateClassMap($classMap); } /** @@ -49,6 +56,7 @@ public function __construct( */ public function add(string $className, Closure $resolver): void { + $this->assertClassString($className); $this->classMap[$className] = $resolver; } @@ -103,6 +111,28 @@ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $ return $resolver($json); } + /** + * Validates the configured class map entries eagerly. + * + * @param array $classMap + * + * @return array + */ + private function validateClassMap(array $classMap): array + { + foreach ($classMap as $sourceClass => $mapping) { + $this->assertClassString($sourceClass); + + if ($mapping instanceof Closure) { + continue; + } + + $this->assertClassString($mapping); + } + + return $classMap; + } + /** * @return class-string * diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 607553a..96c196b 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -11,6 +11,8 @@ namespace MagicSunday\JsonMapper\Type; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -22,25 +24,107 @@ */ final class TypeResolver { + private const CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; + private BuiltinType $defaultType; public function __construct( private readonly PropertyInfoExtractorInterface $extractor, + private readonly ?CacheItemPoolInterface $cache = null, ) { $this->defaultType = new BuiltinType(TypeIdentifier::STRING); } /** * Resolves the declared type for the provided property. + * + * @param class-string $className + * @param string $propertyName + * + * @return Type */ public function resolve(string $className, string $propertyName): Type { + $cached = $this->getCachedType($className, $propertyName); + + if ($cached instanceof Type) { + return $cached; + } + $type = $this->extractor->getType($className, $propertyName); if ($type instanceof UnionType) { - return $type->getTypes()[0]; + $type = $type->getTypes()[0]; + } + + $resolved = $type ?? $this->defaultType; + + $this->storeCachedType($className, $propertyName, $resolved); + + return $resolved; + } + + /** + * Returns a cached type if available. + * + * @param class-string $className + * @param string $propertyName + * + * @return Type|null + */ + private function getCachedType(string $className, string $propertyName): ?Type + { + if ($this->cache === null) { + return null; + } + + try { + $item = $this->cache->getItem($this->buildCacheKey($className, $propertyName)); + } catch (CacheInvalidArgumentException) { + return null; } - return $type ?? $this->defaultType; + if (!$item->isHit()) { + return null; + } + + $cached = $item->get(); + + return $cached instanceof Type ? $cached : null; + } + + /** + * Stores the resolved type in cache when possible. + * + * @param class-string $className + * @param string $propertyName + * @param Type $type + */ + private function storeCachedType(string $className, string $propertyName, Type $type): void + { + if ($this->cache === null) { + return; + } + + try { + $item = $this->cache->getItem($this->buildCacheKey($className, $propertyName)); + $item->set($type); + $this->cache->save($item); + } catch (CacheInvalidArgumentException) { + // Intentionally ignored: caching failures must not block type resolution. + } + } + + /** + * Builds a cache key that fulfils PSR-6 requirements. + * + * @param class-string $className + * @param string $propertyName + * + * @return string + */ + private function buildCacheKey(string $className, string $propertyName): string + { + return self::CACHE_KEY_PREFIX . strtr($className, '\\', '_') . '.' . $propertyName; } } diff --git a/tests/JsonMapper/Type/TypeResolverTest.php b/tests/JsonMapper/Type/TypeResolverTest.php new file mode 100644 index 0000000..5af20a8 --- /dev/null +++ b/tests/JsonMapper/Type/TypeResolverTest.php @@ -0,0 +1,263 @@ +resolve(TypeResolverFixture::class, 'baz'); + $second = $resolver->resolve(TypeResolverFixture::class, 'baz'); + + self::assertSame($first, $second); + self::assertTrue($first->isIdentifiedBy(TypeIdentifier::INT)); + self::assertSame(1, $typeExtractor->callCount); + } + + #[Test] + public function itNormalizesUnionTypesBeforeCaching(): void + { + $typeExtractor = new StubPropertyTypeExtractor( + new UnionType( + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::STRING), + ), + ); + $extractor = new PropertyInfoExtractor([], [$typeExtractor]); + $cache = new InMemoryCachePool(); + $resolver = new TypeResolver($extractor, $cache); + + $type = $resolver->resolve(TypeResolverFixture::class, 'qux'); + + self::assertTrue($type->isIdentifiedBy(TypeIdentifier::INT)); + self::assertSame($type, $resolver->resolve(TypeResolverFixture::class, 'qux')); + self::assertSame(1, $typeExtractor->callCount); + } + + #[Test] + public function itFallsBackToStringType(): void + { + $typeExtractor = new StubPropertyTypeExtractor(null); + $extractor = new PropertyInfoExtractor([], [$typeExtractor]); + $resolver = new TypeResolver($extractor, new InMemoryCachePool()); + + $type = $resolver->resolve(TypeResolverFixture::class, 'name'); + + self::assertInstanceOf(BuiltinType::class, $type); + self::assertTrue($type->isIdentifiedBy(TypeIdentifier::STRING)); + self::assertSame(1, $typeExtractor->callCount); + } +} + +/** + * Lightweight in-memory cache pool implementation for testing purposes only. + */ +final class InMemoryCachePool implements CacheItemPoolInterface +{ + /** + * @var array + */ + private array $items = []; + + public function getItem(string $key): CacheItemInterface + { + if (!array_key_exists($key, $this->items)) { + return new InMemoryCacheItem($key); + } + + return $this->items[$key]; + } + + /** + * @param string[] $keys + * + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + $items = []; + + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + public function hasItem(string $key): bool + { + return array_key_exists($key, $this->items) && $this->items[$key]->isHit(); + } + + public function clear(): bool + { + $this->items = []; + + return true; + } + + public function deleteItem(string $key): bool + { + unset($this->items[$key]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->items[$key]); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item instanceof InMemoryCacheItem + ? $item + : new InMemoryCacheItem($item->getKey(), $item->get(), $item->isHit()); + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function commit(): bool + { + return true; + } +} + +/** + * @internal + */ +final class InMemoryCacheItem implements CacheItemInterface +{ + public function __construct( + private readonly string $key, + private mixed $value = null, + private bool $hit = false, + ) { + } + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->hit; + } + + public function set(mixed $value): static + { + $this->value = $value; + $this->hit = true; + + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static + { + return $this; + } + + public function expiresAfter(DateInterval|int|null $time): static + { + return $this; + } +} + +/** + * Simple type extractor stub that records calls and returns configured types. + */ +final class StubPropertyTypeExtractor implements PropertyTypeExtractorInterface +{ + /** + * @var array + */ + private array $results; + + private int $index = 0; + + public int $callCount = 0; + + public function __construct(?Type ...$results) + { + $this->results = array_values($results); + } + + /** + * @param array $context + */ + public function getType(string $class, string $property, array $context = []): ?Type + { + ++$this->callCount; + + if (!array_key_exists($this->index, $this->results)) { + return null; + } + + return $this->results[$this->index++]; + } + + /** + * @param array $context + */ + public function getTypes(string $class, string $property, array $context = []): ?array + { + return null; + } +} + +/** + * @internal + */ +final class TypeResolverFixture +{ +} From 99c5fe56cf419716ef2e7d7681ec0d35109e41f8 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 22:20:48 +0100 Subject: [PATCH 04/46] Refine error handling while preserving legacy context APIs --- src/JsonMapper.php | 157 +++++++++++++- .../Collection/CollectionFactory.php | 27 ++- .../Configuration/MappingConfiguration.php | 73 +++++++ src/JsonMapper/Context/MappingContext.php | 68 +++++- src/JsonMapper/Context/MappingError.php | 42 ++++ .../Exception/CollectionMappingException.php | 33 +++ src/JsonMapper/Exception/MappingException.php | 35 ++++ .../Exception/MissingPropertyException.php | 45 ++++ .../Exception/TypeMismatchException.php | 41 ++++ .../Exception/UnknownPropertyException.php | 45 ++++ src/JsonMapper/Report/MappingReport.php | 45 ++++ src/JsonMapper/Report/MappingResult.php | 34 +++ src/JsonMapper/Type/TypeResolver.php | 76 +++++++ .../BuiltinValueConversionStrategy.php | 66 ++++++ .../CollectionValueConversionStrategy.php | 4 +- .../ObjectValueConversionStrategy.php | 14 ++ .../MappingConfigurationTest.php | 71 +++++++ .../JsonMapperErrorHandlingTest.php | 198 ++++++++++++++++++ tests/JsonMapper/Report/MappingReportTest.php | 46 ++++ tests/JsonMapper/Report/MappingResultTest.php | 38 ++++ 20 files changed, 1128 insertions(+), 30 deletions(-) create mode 100644 src/JsonMapper/Configuration/MappingConfiguration.php create mode 100644 src/JsonMapper/Context/MappingError.php create mode 100644 src/JsonMapper/Exception/CollectionMappingException.php create mode 100644 src/JsonMapper/Exception/MappingException.php create mode 100644 src/JsonMapper/Exception/MissingPropertyException.php create mode 100644 src/JsonMapper/Exception/TypeMismatchException.php create mode 100644 src/JsonMapper/Exception/UnknownPropertyException.php create mode 100644 src/JsonMapper/Report/MappingReport.php create mode 100644 src/JsonMapper/Report/MappingResult.php create mode 100644 tests/JsonMapper/Configuration/MappingConfigurationTest.php create mode 100644 tests/JsonMapper/JsonMapperErrorHandlingTest.php create mode 100644 tests/JsonMapper/Report/MappingReportTest.php create mode 100644 tests/JsonMapper/Report/MappingResultTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index e2afea8..34072e6 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -18,8 +18,14 @@ use MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue; use MagicSunday\JsonMapper\Annotation\ReplaceProperty; use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; +use MagicSunday\JsonMapper\Exception\MappingException; +use MagicSunday\JsonMapper\Exception\MissingPropertyException; +use MagicSunday\JsonMapper\Exception\UnknownPropertyException; +use MagicSunday\JsonMapper\Report\MappingReport; +use MagicSunday\JsonMapper\Report\MappingResult; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Type\TypeResolver; use MagicSunday\JsonMapper\Value\CustomTypeRegistry; @@ -34,7 +40,9 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; +use ReflectionNamedType; use ReflectionProperty; +use ReflectionUnionType; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; @@ -42,7 +50,11 @@ use Symfony\Component\TypeInfo\Type\ObjectType; use Traversable; +use function array_diff; +use function array_filter; use function array_key_exists; +use function array_unique; +use function array_values; use function call_user_func_array; use function count; use function get_object_vars; @@ -111,7 +123,11 @@ function (string $className, ?array $arguments): object { $this->valueConverter->addStrategy( new ObjectValueConversionStrategy( $this->classResolver, - fn (mixed $value, string $resolvedClass, MappingContext $context): mixed => $this->map($value, $resolvedClass, null, $context), + function (mixed $value, string $resolvedClass, MappingContext $context): mixed { + $configuration = MappingConfiguration::fromContext($context); + + return $this->map($value, $resolvedClass, null, $context, $configuration); + }, ), ); $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); @@ -158,8 +174,18 @@ public function map( ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, + ?MappingConfiguration $configuration = null, ): mixed { - $context ??= new MappingContext($json); + if ($context === null) { + $configuration ??= MappingConfiguration::lenient(); + $context = new MappingContext($json, $configuration->toOptions()); + } else { + if ($configuration === null) { + $configuration = MappingConfiguration::fromContext($context); + } else { + $context->replaceOptions($configuration->toOptions()); + } + } if ($className === null) { return $json; @@ -200,24 +226,37 @@ public function map( $properties = $this->getProperties($resolvedClassName); $replacePropertyMap = $this->buildReplacePropertyMap($resolvedClassName); + $mappedProperties = []; foreach ($source as $propertyName => $propertyValue) { $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); + $pathSegment = is_string($normalizedProperty) ? $normalizedProperty : (string) $propertyName; - if (!is_string($normalizedProperty)) { - continue; - } - - if (!in_array($normalizedProperty, $properties, true)) { - continue; - } - - $context->withPathSegment($normalizedProperty, function (MappingContext $propertyContext) use ( + $context->withPathSegment($pathSegment, function (MappingContext $propertyContext) use ( $resolvedClassName, $normalizedProperty, $propertyValue, $entity, + &$mappedProperties, + $properties, + $configuration, ): void { + if (!is_string($normalizedProperty)) { + return; + } + + if (!in_array($normalizedProperty, $properties, true)) { + $this->handleMappingException( + new UnknownPropertyException($propertyContext->getPath(), $normalizedProperty, $resolvedClassName), + $propertyContext, + $configuration, + ); + + return; + } + + $mappedProperties[] = $normalizedProperty; + $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); $value = $this->convertValue($propertyValue, $type, $propertyContext); @@ -232,9 +271,105 @@ public function map( }); } + if ($configuration->isStrictMode()) { + foreach ($this->determineMissingProperties($resolvedClassName, $properties, $mappedProperties) as $missingProperty) { + $context->withPathSegment($missingProperty, function (MappingContext $propertyContext) use ( + $resolvedClassName, + $missingProperty, + $configuration, + ): void { + $this->handleMappingException( + new MissingPropertyException($propertyContext->getPath(), $missingProperty, $resolvedClassName), + $propertyContext, + $configuration, + ); + }); + } + } + return $entity; } + /** + * Maps the JSON structure and returns a detailed mapping report. + * + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName + */ + public function mapWithReport( + mixed $json, + ?string $className = null, + ?string $collectionClassName = null, + ?MappingConfiguration $configuration = null, + ): MappingResult { + $configuration = ($configuration ?? MappingConfiguration::lenient())->withErrorCollection(true); + $context = new MappingContext($json, $configuration->toOptions()); + $value = $this->map($json, $className, $collectionClassName, $context, $configuration); + + return new MappingResult($value, new MappingReport($context->getErrorRecords())); + } + + /** + * @param class-string $className + * @param array $declaredProperties + * @param list $mappedProperties + * + * @return list + */ + private function determineMissingProperties(string $className, array $declaredProperties, array $mappedProperties): array + { + $used = array_values(array_unique($mappedProperties)); + + return array_values(array_filter( + array_diff($declaredProperties, $used), + fn (string $property): bool => $this->isRequiredProperty($className, $property), + )); + } + + /** + * @param class-string $className + */ + private function isRequiredProperty(string $className, string $propertyName): bool + { + $reflectionProperty = $this->getReflectionProperty($className, $propertyName); + + if (!($reflectionProperty instanceof ReflectionProperty)) { + return false; + } + + if ($reflectionProperty->hasDefaultValue()) { + return false; + } + + $type = $reflectionProperty->getType(); + + if ($type instanceof ReflectionNamedType) { + return !$type->allowsNull(); + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $innerType) { + if ($innerType instanceof ReflectionNamedType && $innerType->allowsNull()) { + return false; + } + } + + return true; + } + + return false; + } + + private function handleMappingException(MappingException $exception, MappingContext $context, MappingConfiguration $configuration): void + { + $context->recordException($exception); + + if ($configuration->isStrictMode()) { + throw $exception; + } + } + /** * Converts the provided JSON value using the registered strategies. */ diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index db14ccd..c37b69f 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -14,6 +14,7 @@ use Closure; use DomainException; use MagicSunday\JsonMapper\Context\MappingContext; +use MagicSunday\JsonMapper\Exception\CollectionMappingException; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Value\ValueConverter; use Symfony\Component\TypeInfo\Type; @@ -22,6 +23,7 @@ use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Traversable; +use function get_debug_type; use function get_object_vars; use function is_array; use function is_object; @@ -45,23 +47,32 @@ public function __construct( /** * Converts the provided iterable JSON structure to a PHP array. * - * @param array|object|null $json - * * @return array|null */ - public function mapIterable(array|object|null $json, Type $valueType, MappingContext $context): ?array + public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array { if ($json === null) { return null; } - /** @var array $source */ $source = match (true) { $json instanceof Traversable => iterator_to_array($json), + is_array($json) => $json, is_object($json) => get_object_vars($json), - default => $json, + default => null, }; + if (!is_array($source)) { + $exception = new CollectionMappingException($context->getPath(), get_debug_type($json)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + + return null; + } + $collection = []; foreach ($source as $key => $value) { @@ -78,11 +89,7 @@ public function mapIterable(array|object|null $json, Type $valueType, MappingCon */ public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed { - $collection = $this->mapIterable( - is_array($json) || is_object($json) ? $json : null, - $type->getCollectionValueType(), - $context, - ); + $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); $wrappedType = $type->getWrappedType(); diff --git a/src/JsonMapper/Configuration/MappingConfiguration.php b/src/JsonMapper/Configuration/MappingConfiguration.php new file mode 100644 index 0000000..794e91c --- /dev/null +++ b/src/JsonMapper/Configuration/MappingConfiguration.php @@ -0,0 +1,73 @@ +isStrictMode(), + $context->shouldCollectErrors(), + ); + } + + public function withErrorCollection(bool $collect): self + { + $clone = clone $this; + $clone->collectErrors = $collect; + + return $clone; + } + + public function isStrictMode(): bool + { + return $this->strictMode; + } + + public function shouldCollectErrors(): bool + { + return $this->collectErrors; + } + + /** + * @return array + */ + public function toOptions(): array + { + return [ + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + ]; + } +} diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index afcf61a..8cf2959 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -11,27 +11,38 @@ namespace MagicSunday\JsonMapper\Context; +use MagicSunday\JsonMapper\Exception\MappingException; + /** * Represents the state shared while mapping JSON structures. */ final class MappingContext { + public const OPTION_STRICT_MODE = 'strict_mode'; + public const OPTION_COLLECT_ERRORS = 'collect_errors'; + /** * @var list */ private array $pathSegments = []; /** - * @var list + * @var list + */ + private array $errorRecords = []; + + /** + * @var array */ - private array $errors = []; + private array $options; /** * @param mixed $rootInput The original JSON payload * @param array $options Context options */ - public function __construct(private readonly mixed $rootInput, private readonly array $options = []) + public function __construct(private readonly mixed $rootInput, array $options = []) { + $this->options = $options; } /** @@ -73,9 +84,21 @@ public function withPathSegment(string|int $segment, callable $callback): mixed /** * Stores the error message for later consumption. */ - public function addError(string $message): void + public function addError(string $message, ?MappingException $exception = null): void + { + if (!$this->shouldCollectErrors()) { + return; + } + + $this->errorRecords[] = new MappingError($this->getPath(), $message, $exception); + } + + /** + * Stores the exception and message for later consumption. + */ + public function recordException(MappingException $exception): void { - $this->errors[] = $message; + $this->addError($exception->getMessage(), $exception); } /** @@ -85,7 +108,20 @@ public function addError(string $message): void */ public function getErrors(): array { - return $this->errors; + return array_map( + static fn (MappingError $error): string => $error->getMessage(), + $this->errorRecords, + ); + } + + public function shouldCollectErrors(): bool + { + return (bool) ($this->options[self::OPTION_COLLECT_ERRORS] ?? true); + } + + public function isStrictMode(): bool + { + return (bool) ($this->options[self::OPTION_STRICT_MODE] ?? false); } /** @@ -105,4 +141,24 @@ public function getOption(string $name, mixed $default = null): mixed { return $this->options[$name] ?? $default; } + + /** + * Replaces the stored options. + * + * @param array $options + */ + public function replaceOptions(array $options): void + { + $this->options = $options; + } + + /** + * Returns collected mapping errors with contextual details. + * + * @return list + */ + public function getErrorRecords(): array + { + return $this->errorRecords; + } } diff --git a/src/JsonMapper/Context/MappingError.php b/src/JsonMapper/Context/MappingError.php new file mode 100644 index 0000000..6bcc624 --- /dev/null +++ b/src/JsonMapper/Context/MappingError.php @@ -0,0 +1,42 @@ +path; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getException(): ?MappingException + { + return $this->exception; + } +} diff --git a/src/JsonMapper/Exception/CollectionMappingException.php b/src/JsonMapper/Exception/CollectionMappingException.php new file mode 100644 index 0000000..22824db --- /dev/null +++ b/src/JsonMapper/Exception/CollectionMappingException.php @@ -0,0 +1,33 @@ +actualType; + } +} diff --git a/src/JsonMapper/Exception/MappingException.php b/src/JsonMapper/Exception/MappingException.php new file mode 100644 index 0000000..6327a43 --- /dev/null +++ b/src/JsonMapper/Exception/MappingException.php @@ -0,0 +1,35 @@ +path; + } +} diff --git a/src/JsonMapper/Exception/MissingPropertyException.php b/src/JsonMapper/Exception/MissingPropertyException.php new file mode 100644 index 0000000..bc3344d --- /dev/null +++ b/src/JsonMapper/Exception/MissingPropertyException.php @@ -0,0 +1,45 @@ +propertyName; + } + + /** + * @return class-string + */ + public function getClassName(): string + { + return $this->className; + } +} diff --git a/src/JsonMapper/Exception/TypeMismatchException.php b/src/JsonMapper/Exception/TypeMismatchException.php new file mode 100644 index 0000000..4c76130 --- /dev/null +++ b/src/JsonMapper/Exception/TypeMismatchException.php @@ -0,0 +1,41 @@ +expectedType; + } + + public function getActualType(): string + { + return $this->actualType; + } +} diff --git a/src/JsonMapper/Exception/UnknownPropertyException.php b/src/JsonMapper/Exception/UnknownPropertyException.php new file mode 100644 index 0000000..2d81044 --- /dev/null +++ b/src/JsonMapper/Exception/UnknownPropertyException.php @@ -0,0 +1,45 @@ +propertyName; + } + + /** + * @return class-string + */ + public function getClassName(): string + { + return $this->className; + } +} diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php new file mode 100644 index 0000000..9195094 --- /dev/null +++ b/src/JsonMapper/Report/MappingReport.php @@ -0,0 +1,45 @@ + $errors + */ + public function __construct(private readonly array $errors) + { + } + + /** + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + public function hasErrors(): bool + { + return $this->errors !== []; + } + + public function getErrorCount(): int + { + return count($this->errors); + } +} diff --git a/src/JsonMapper/Report/MappingResult.php b/src/JsonMapper/Report/MappingResult.php new file mode 100644 index 0000000..0861e22 --- /dev/null +++ b/src/JsonMapper/Report/MappingResult.php @@ -0,0 +1,34 @@ +value; + } + + public function getReport(): MappingReport + { + return $this->report; + } +} diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 96c196b..e7aa5d9 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -13,6 +13,10 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; +use ReflectionException; +use ReflectionNamedType; +use ReflectionProperty; +use ReflectionUnionType; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -57,6 +61,10 @@ public function resolve(string $className, string $propertyName): Type $type = $type->getTypes()[0]; } + if ($type === null) { + $type = $this->resolveFromReflection($className, $propertyName); + } + $resolved = $type ?? $this->defaultType; $this->storeCachedType($className, $propertyName, $resolved); @@ -127,4 +135,72 @@ private function buildCacheKey(string $className, string $propertyName): string { return self::CACHE_KEY_PREFIX . strtr($className, '\\', '_') . '.' . $propertyName; } + + /** + * @param class-string $className + */ + private function resolveFromReflection(string $className, string $propertyName): ?Type + { + try { + $property = new ReflectionProperty($className, $propertyName); + } catch (ReflectionException) { + return null; + } + + $reflectionType = $property->getType(); + + if ($reflectionType instanceof ReflectionNamedType) { + return $this->createTypeFromNamedReflection($reflectionType); + } + + if ($reflectionType instanceof ReflectionUnionType) { + $allowsNull = false; + $primary = null; + + foreach ($reflectionType->getTypes() as $innerType) { + if (!$innerType instanceof ReflectionNamedType) { + continue; + } + + if ($innerType->getName() === 'null') { + $allowsNull = true; + + continue; + } + + $primary ??= $innerType; + } + + if ($primary instanceof ReflectionNamedType) { + return $this->createTypeFromNamedReflection($primary, $allowsNull || $primary->allowsNull()); + } + } + + return null; + } + + private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool $nullable = null): ?Type + { + $name = $type->getName(); + + if ($type->isBuiltin()) { + $identifier = TypeIdentifier::tryFrom($name); + + if ($identifier === null) { + return null; + } + + $resolved = Type::builtin($identifier); + } else { + $resolved = Type::object($name); + } + + $allowsNull = $nullable ?? $type->allowsNull(); + + if ($allowsNull) { + return Type::nullable($resolved); + } + + return $resolved; + } } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index c718c53..cf0d1c5 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -12,10 +12,22 @@ namespace MagicSunday\JsonMapper\Value\Strategy; use MagicSunday\JsonMapper\Context\MappingContext; +use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Traversable; use function assert; +use function get_debug_type; +use function is_array; +use function is_bool; +use function is_callable; +use function is_float; +use function is_int; +use function is_object; +use function is_string; +use function settype; /** * Converts scalar values to the requested builtin type. @@ -31,9 +43,63 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe { assert($type instanceof BuiltinType); + $this->guardCompatibility($value, $type, $context); + $converted = $value; settype($converted, $type->getTypeIdentifier()->value); return $converted; } + + private function guardCompatibility(mixed $value, BuiltinType $type, MappingContext $context): void + { + $identifier = $type->getTypeIdentifier(); + + if ($value === null) { + if ($this->allowsNull($type)) { + return; + } + + $exception = new TypeMismatchException($context->getPath(), $identifier->value, 'null'); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + + return; + } + + if ($this->isCompatibleValue($value, $identifier)) { + return; + } + + $exception = new TypeMismatchException($context->getPath(), $identifier->value, get_debug_type($value)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + } + + private function allowsNull(BuiltinType $type): bool + { + return $type->isNullable(); + } + + private function isCompatibleValue(mixed $value, TypeIdentifier $identifier): bool + { + return match ($identifier->value) { + 'int' => is_int($value), + 'float' => is_float($value) || is_int($value), + 'bool' => is_bool($value), + 'string' => is_string($value), + 'array' => is_array($value), + 'object' => is_object($value), + 'callable' => is_callable($value), + 'iterable' => is_array($value) || $value instanceof Traversable, + 'null' => $value === null, + default => true, + }; + } } diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index 44dc1b3..6fdff27 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -17,8 +17,6 @@ use Symfony\Component\TypeInfo\Type\CollectionType; use function assert; -use function is_array; -use function is_object; /** * Converts collection values using the configured factory. @@ -32,7 +30,7 @@ public function __construct( public function supports(mixed $value, Type $type, MappingContext $context): bool { - return ($type instanceof CollectionType) && (is_array($value) || is_object($value) || $value === null); + return $type instanceof CollectionType; } public function convert(mixed $value, Type $type, MappingContext $context): mixed diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 3f955d0..1fcc88c 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -14,10 +14,15 @@ use Closure; use LogicException; use MagicSunday\JsonMapper\Context\MappingContext; +use MagicSunday\JsonMapper\Exception\TypeMismatchException; use MagicSunday\JsonMapper\Resolver\ClassResolver; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; +use function get_debug_type; +use function is_array; +use function is_object; + /** * Converts object values by delegating to the mapper callback. */ @@ -46,6 +51,15 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe $className = $this->resolveClassName($type); $resolvedClass = $this->classResolver->resolve($className, $value, $context); + if (($value !== null) && !is_array($value) && !is_object($value)) { + $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + } + $mapper = $this->mapper; return $mapper($value, $resolvedClass, $context); diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php new file mode 100644 index 0000000..971a479 --- /dev/null +++ b/tests/JsonMapper/Configuration/MappingConfigurationTest.php @@ -0,0 +1,71 @@ +isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + } + + #[Test] + public function itEnablesStrictMode(): void + { + $configuration = MappingConfiguration::strict(); + + self::assertTrue($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + } + + #[Test] + public function itSupportsTogglingErrorCollection(): void + { + $configuration = MappingConfiguration::lenient()->withErrorCollection(false); + + self::assertFalse($configuration->isStrictMode()); + self::assertFalse($configuration->shouldCollectErrors()); + } + + #[Test] + public function itDerivesFromContext(): void + { + $context = new MappingContext([], [ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + ]); + + $configuration = MappingConfiguration::fromContext($context); + + self::assertTrue($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertSame( + [ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + ], + $configuration->toOptions(), + ); + } +} diff --git a/tests/JsonMapper/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php new file mode 100644 index 0000000..793138d --- /dev/null +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -0,0 +1,198 @@ +getJsonMapper() + ->mapWithReport([ + 'name' => 'John Doe', + 'unknown' => 'value', + ], Person::class); + + self::assertInstanceOf(Person::class, $result->getValue()); + + $report = $result->getReport(); + self::assertTrue($report->hasErrors()); + self::assertSame(1, $report->getErrorCount()); + + $error = $report->getErrors()[0]; + self::assertSame('Unknown property $.unknown on ' . Person::class . '.', $error->getMessage()); + self::assertInstanceOf(UnknownPropertyException::class, $error->getException()); + } + + #[Test] + public function itThrowsOnUnknownPropertiesInStrictMode(): void + { + $this->expectException(UnknownPropertyException::class); + + $this->getJsonMapper() + ->map( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnMissingRequiredProperties(): void + { + $this->expectException(MissingPropertyException::class); + + $this->getJsonMapper() + ->map( + [], + Person::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnTypeMismatch(): void + { + $this->expectException(TypeMismatchException::class); + + $this->getJsonMapper() + ->map( + ['name' => 123], + Base::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnInvalidCollectionPayloads(): void + { + $this->expectException(CollectionMappingException::class); + + $this->getJsonMapper() + ->map( + [ + 'name' => 'John Doe', + 'simpleArray' => 'invalid', + ], + Base::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itReportsTypeMismatchesInLenientMode(): void + { + $result = $this->getJsonMapper() + ->mapWithReport( + ['name' => 123], + Base::class, + ); + + $report = $result->getReport(); + self::assertTrue($report->hasErrors()); + + $exception = $report->getErrors()[0]->getException(); + self::assertInstanceOf(TypeMismatchException::class, $exception); + } + + #[Test] + public function itCollectsNestedErrorsAcrossObjectGraphs(): void + { + $result = $this->getJsonMapper() + ->mapWithReport( + [ + 'simple' => [ + 'int' => 'oops', + 'name' => 456, + 'unknown' => 'value', + ], + ], + Base::class, + ); + + $errors = $result->getReport()->getErrors(); + + self::assertCount(3, $errors); + + $errorsByPath = []; + foreach ($errors as $error) { + $errorsByPath[$error->getPath()] = $error; + } + + self::assertArrayHasKey('$.simple.int', $errorsByPath); + self::assertSame( + 'Type mismatch at $.simple.int: expected int, got string.', + $errorsByPath['$.simple.int']->getMessage(), + ); + self::assertInstanceOf(TypeMismatchException::class, $errorsByPath['$.simple.int']->getException()); + + self::assertArrayHasKey('$.simple.name', $errorsByPath); + self::assertSame( + 'Type mismatch at $.simple.name: expected string, got int.', + $errorsByPath['$.simple.name']->getMessage(), + ); + self::assertInstanceOf(TypeMismatchException::class, $errorsByPath['$.simple.name']->getException()); + + self::assertArrayHasKey('$.simple.unknown', $errorsByPath); + self::assertSame( + 'Unknown property $.simple.unknown on ' . Simple::class . '.', + $errorsByPath['$.simple.unknown']->getMessage(), + ); + self::assertInstanceOf(UnknownPropertyException::class, $errorsByPath['$.simple.unknown']->getException()); + } + + #[Test] + public function itThrowsOnInvalidNestedCollectionEntriesInStrictMode(): void + { + $this->expectException(TypeMismatchException::class); + $this->expectExceptionMessage('Type mismatch at $.simpleArray.1.int: expected int, got string.'); + + $this->getJsonMapper() + ->map( + [ + 'simpleArray' => [ + ['id' => 1, 'int' => 1, 'name' => 'Valid'], + ['id' => 2, 'int' => 'oops', 'name' => 'Broken'], + ], + ], + Base::class, + null, + null, + MappingConfiguration::strict(), + ); + } +} diff --git a/tests/JsonMapper/Report/MappingReportTest.php b/tests/JsonMapper/Report/MappingReportTest.php new file mode 100644 index 0000000..8f765c9 --- /dev/null +++ b/tests/JsonMapper/Report/MappingReportTest.php @@ -0,0 +1,46 @@ +hasErrors()); + self::assertSame(1, $report->getErrorCount()); + self::assertSame($errors, $report->getErrors()); + } + + #[Test] + public function itHandlesEmptyReports(): void + { + $report = new MappingReport([]); + + self::assertFalse($report->hasErrors()); + self::assertSame(0, $report->getErrorCount()); + } +} diff --git a/tests/JsonMapper/Report/MappingResultTest.php b/tests/JsonMapper/Report/MappingResultTest.php new file mode 100644 index 0000000..0583e3c --- /dev/null +++ b/tests/JsonMapper/Report/MappingResultTest.php @@ -0,0 +1,38 @@ + 'bar'], $report); + + self::assertSame(['foo' => 'bar'], $result->getValue()); + self::assertSame($report, $result->getReport()); + } +} From f5eec80869cedd52106b43a178866802811dc937 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 22:48:38 +0100 Subject: [PATCH 05/46] Add PHP 8+ attribute support and modern type conversions --- README.md | 19 +- composer.json | 3 +- src/JsonMapper.php | 274 +++++++++++++----- .../ReplaceNullWithDefaultValue.php | 30 -- src/JsonMapper/Annotation/ReplaceProperty.php | 36 --- .../Attribute/ReplaceNullWithDefaultValue.php | 22 ++ src/JsonMapper/Attribute/ReplaceProperty.php | 27 ++ .../Configuration/MappingConfiguration.php | 20 +- src/JsonMapper/Context/MappingContext.php | 18 +- .../Exception/ReadonlyPropertyException.php | 28 ++ src/JsonMapper/Type/TypeResolver.php | 72 ++++- .../BuiltinValueConversionStrategy.php | 76 ++++- .../DateTimeValueConversionStrategy.php | 87 ++++++ .../Strategy/EnumValueConversionStrategy.php | 80 +++++ .../ReplacePropertyTest.php | 2 +- tests/Classes/DateTimeHolder.php | 22 ++ tests/Classes/EnumHolder.php | 19 ++ tests/Classes/Initialized.php | 11 +- tests/Classes/NullableStringHolder.php | 17 ++ tests/Classes/ReadonlyEntity.php | 22 ++ tests/Classes/ReplacePropertyTestClass.php | 7 +- tests/Classes/ScalarHolder.php | 21 ++ tests/Classes/UnionHolder.php | 19 ++ tests/Fixtures/Enum/SampleStatus.php | 18 ++ .../MappingConfigurationTest.php | 13 + .../JsonMapperErrorHandlingTest.php | 21 ++ tests/JsonMapperTest.php | 117 +++++++- 27 files changed, 914 insertions(+), 187 deletions(-) delete mode 100644 src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php delete mode 100644 src/JsonMapper/Annotation/ReplaceProperty.php create mode 100644 src/JsonMapper/Attribute/ReplaceNullWithDefaultValue.php create mode 100644 src/JsonMapper/Attribute/ReplaceProperty.php create mode 100644 src/JsonMapper/Exception/ReadonlyPropertyException.php create mode 100644 src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php rename tests/{Annotation => Attribute}/ReplacePropertyTest.php (97%) create mode 100644 tests/Classes/DateTimeHolder.php create mode 100644 tests/Classes/EnumHolder.php create mode 100644 tests/Classes/NullableStringHolder.php create mode 100644 tests/Classes/ReadonlyEntity.php create mode 100644 tests/Classes/ScalarHolder.php create mode 100644 tests/Classes/UnionHolder.php create mode 100644 tests/Fixtures/Enum/SampleStatus.php diff --git a/README.md b/README.md index f39b5e5..6e53d9e 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,13 @@ For example: ``` -#### Custom annotations +#### Custom attributes Sometimes its may be required to circumvent the limitations of a poorly designed API. Together with custom -annotations it becomes possible to fix some API design issues (e.g. mismatch between documentation and webservice +attributes it becomes possible to fix some API design issues (e.g. mismatch between documentation and webservice response), to create a clean SDK. -##### @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue -This annotation is used to inform the JsonMapper that an existing default value should be used when +##### #[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] +This attribute is used to inform the JsonMapper that an existing default value should be used when setting a property, if the value derived from the JSON is a NULL value instead of the expected property type. This can be necessary, for example, in the case of a bad API design, if the API documentation defines a @@ -54,23 +54,20 @@ instead of an empty array that can be expected. ```php /** * @var array - * - * @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue */ +#[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] public array $array = []; ``` If the mapping tries to assign NULL to the property, the default value will be used, as annotated. -##### @MagicSunday\JsonMapper\Annotation\ReplaceProperty -This annotation is used to inform the JsonMapper to replace one or more properties with another one. It's +##### #[MagicSunday\JsonMapper\Attribute\ReplaceProperty] +This attribute is used to inform the JsonMapper to replace one or more properties with another one. It's used in class context. For instance if you want to replace a cryptic named property to a more human-readable name. ```php -/** - * @MagicSunday\JsonMapper\Annotation\ReplaceProperty("type", replaces="crypticTypeNameProperty") - */ +#[MagicSunday\JsonMapper\Attribute\ReplaceProperty('type', replaces: 'crypticTypeNameProperty')] class FooClass { /** diff --git a/composer.json b/composer.json index 3fdf156..9d700ef 100644 --- a/composer.json +++ b/composer.json @@ -19,8 +19,7 @@ "symfony/property-info": "^7.3", "symfony/property-access": "^7.3", "symfony/type-info": "^7.3", - "doctrine/inflector": "^2.0", - "doctrine/annotations": "^2.0" + "doctrine/inflector": "^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.65", diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 34072e6..675b096 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -12,17 +12,17 @@ namespace MagicSunday; use Closure; -use Doctrine\Common\Annotations\Annotation; -use Doctrine\Common\Annotations\AnnotationReader; use InvalidArgumentException; -use MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue; -use MagicSunday\JsonMapper\Annotation\ReplaceProperty; +use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; +use MagicSunday\JsonMapper\Attribute\ReplaceProperty; use MagicSunday\JsonMapper\Collection\CollectionFactory; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; use MagicSunday\JsonMapper\Exception\MappingException; use MagicSunday\JsonMapper\Exception\MissingPropertyException; +use MagicSunday\JsonMapper\Exception\ReadonlyPropertyException; +use MagicSunday\JsonMapper\Exception\TypeMismatchException; use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\JsonMapper\Report\MappingReport; use MagicSunday\JsonMapper\Report\MappingResult; @@ -32,11 +32,14 @@ use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\CollectionValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\CustomTypeValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\DateTimeValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\EnumValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\NullValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; use MagicSunday\JsonMapper\Value\ValueConverter; use Psr\Cache\CacheItemPoolInterface; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -46,8 +49,11 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; use Traversable; use function array_diff; @@ -57,7 +63,9 @@ use function array_values; use function call_user_func_array; use function count; +use function get_debug_type; use function get_object_vars; +use function implode; use function in_array; use function is_array; use function is_callable; @@ -67,6 +75,7 @@ use function iterator_to_array; use function method_exists; use function sprintf; +use function trim; use function ucfirst; /** @@ -120,6 +129,8 @@ function (string $className, ?array $arguments): object { $this->valueConverter->addStrategy(new NullValueConversionStrategy()); $this->valueConverter->addStrategy(new CollectionValueConversionStrategy($this->collectionFactory)); $this->valueConverter->addStrategy(new CustomTypeValueConversionStrategy($this->customTypeRegistry)); + $this->valueConverter->addStrategy(new DateTimeValueConversionStrategy()); + $this->valueConverter->addStrategy(new EnumValueConversionStrategy()); $this->valueConverter->addStrategy( new ObjectValueConversionStrategy( $this->classResolver, @@ -257,8 +268,15 @@ public function map( $mappedProperties[] = $normalizedProperty; - $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); - $value = $this->convertValue($propertyValue, $type, $propertyContext); + $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); + + try { + $value = $this->convertValue($propertyValue, $type, $propertyContext); + } catch (MappingException $exception) { + $this->handleMappingException($exception, $propertyContext, $configuration); + + return; + } if ( ($value === null) @@ -267,7 +285,11 @@ public function map( $value = $this->getDefaultValue($resolvedClassName, $normalizedProperty); } - $this->setProperty($entity, $normalizedProperty, $value); + try { + $this->setProperty($entity, $normalizedProperty, $value, $propertyContext); + } catch (ReadonlyPropertyException $exception) { + $this->handleMappingException($exception, $propertyContext, $configuration); + } }); } @@ -375,13 +397,148 @@ private function handleMappingException(MappingException $exception, MappingCont */ private function convertValue(mixed $json, Type $type, MappingContext $context): mixed { + if ( + is_string($json) + && ($json === '' || trim($json) === '') + && (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false) + ) { + $json = null; + } + if ($type instanceof CollectionType) { return $this->collectionFactory->fromCollectionType($type, $json, $context); } + if ($type instanceof UnionType) { + return $this->convertUnionValue($json, $type, $context); + } + + if ($this->isNullType($type)) { + return null; + } + return $this->valueConverter->convert($json, $type, $context); } + /** + * Converts the value according to the provided union type. + */ + private function convertUnionValue(mixed $json, UnionType $type, MappingContext $context): mixed + { + if ($json === null && $this->unionAllowsNull($type)) { + return null; + } + + $lastException = null; + + foreach ($type->getTypes() as $candidate) { + if ($this->isNullType($candidate) && $json !== null) { + continue; + } + + $errorCount = $context->getErrorCount(); + + try { + $converted = $this->convertValue($json, $candidate, $context); + } catch (MappingException $exception) { + $context->trimErrors($errorCount); + $lastException = $exception; + + continue; + } + + if ($context->getErrorCount() > $errorCount) { + $context->trimErrors($errorCount); + + $lastException = new TypeMismatchException( + $context->getPath(), + $this->describeType($candidate), + get_debug_type($json), + ); + + continue; + } + + return $converted; + } + + if ($lastException instanceof MappingException) { + throw $lastException; + } + + $exception = new TypeMismatchException( + $context->getPath(), + $this->describeUnionType($type), + get_debug_type($json), + ); + + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + + return $json; + } + + /** + * Returns a string representation of the provided type. + */ + private function describeType(Type $type): string + { + if ($type instanceof BuiltinType) { + return $type->getTypeIdentifier()->value . ($type->isNullable() ? '|null' : ''); + } + + if ($type instanceof ObjectType) { + return $type->getClassName(); + } + + if ($type instanceof CollectionType) { + return 'array'; + } + + if ($this->isNullType($type)) { + return 'null'; + } + + if ($type instanceof UnionType) { + return $this->describeUnionType($type); + } + + return $type::class; + } + + /** + * Returns a textual representation of the union type. + */ + private function describeUnionType(UnionType $type): string + { + $parts = []; + + foreach ($type->getTypes() as $candidate) { + $parts[] = $this->describeType($candidate); + } + + return implode('|', $parts); + } + + private function unionAllowsNull(UnionType $type): bool + { + foreach ($type->getTypes() as $candidate) { + if ($this->isNullType($candidate)) { + return true; + } + } + + return false; + } + + private function isNullType(Type $type): bool + { + return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; + } + /** * Creates an instance of the given class name. * @@ -402,11 +559,13 @@ private function makeInstance(string $className, mixed ...$constructorArguments) */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { - return $this->hasPropertyAnnotation( - $className, - $propertyName, - ReplaceNullWithDefaultValue::class, - ); + $reflectionProperty = $this->getReflectionProperty($className, $propertyName); + + if (!($reflectionProperty instanceof ReflectionProperty)) { + return false; + } + + return $this->hasAttribute($reflectionProperty, ReplaceNullWithDefaultValue::class); } /** @@ -418,23 +577,31 @@ private function isReplaceNullWithDefaultValueAnnotation(string $className, stri */ private function buildReplacePropertyMap(string $className): array { - $map = []; + $reflectionClass = $this->getReflectionClass($className); - foreach ($this->extractClassAnnotations($className) as $annotation) { - if (!($annotation instanceof ReplaceProperty)) { - continue; - } + if (!($reflectionClass instanceof ReflectionClass)) { + return []; + } - if (!is_string($annotation->value)) { - continue; - } + $map = []; - $map[$annotation->replaces] = $annotation->value; + foreach ($reflectionClass->getAttributes(ReplaceProperty::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + /** @var ReplaceProperty $instance */ + $instance = $attribute->newInstance(); + $map[$instance->replaces] = $instance->value; } return $map; } + /** + * @param class-string $attributeClass + */ + private function hasAttribute(ReflectionProperty $property, string $attributeClass): bool + { + return $property->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF) !== []; + } + /** * Normalizes the property name using annotations and converters. * @@ -503,63 +670,6 @@ private function getReflectionClass(string $className): ?ReflectionClass return new ReflectionClass($className); } - /** - * Extracts possible property annotations. - * - * @param class-string $className - * - * @return Annotation[]|object[] - */ - private function extractPropertyAnnotations(string $className, string $propertyName): array - { - $reflectionProperty = $this->getReflectionProperty($className, $propertyName); - - if ($reflectionProperty instanceof ReflectionProperty) { - return (new AnnotationReader()) - ->getPropertyAnnotations($reflectionProperty); - } - - return []; - } - - /** - * Extracts possible class annotations. - * - * @param class-string $className - * - * @return Annotation[]|object[] - */ - private function extractClassAnnotations(string $className): array - { - $reflectionClass = $this->getReflectionClass($className); - - if ($reflectionClass instanceof ReflectionClass) { - return (new AnnotationReader()) - ->getClassAnnotations($reflectionClass); - } - - return []; - } - - /** - * Returns TRUE if the property has the given annotation. - * - * @param class-string $className - * @param class-string $annotationName - */ - private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool - { - $annotations = $this->extractPropertyAnnotations($className, $propertyName); - - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - return true; - } - } - - return false; - } - /** * Returns the default value of a property. * @@ -641,8 +751,14 @@ private function assertClassesExists(string $className, ?string $collectionClass /** * Sets a property value. */ - private function setProperty(object $entity, string $name, mixed $value): void + private function setProperty(object $entity, string $name, mixed $value, MappingContext $context): void { + $reflectionProperty = $this->getReflectionProperty($entity::class, $name); + + if ($reflectionProperty instanceof ReflectionProperty && $reflectionProperty->isReadOnly()) { + throw new ReadonlyPropertyException($context->getPath(), $name, $entity::class); + } + if (is_array($value)) { $methodName = 'set' . ucfirst($name); diff --git a/src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php b/src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php deleted file mode 100644 index 740c687..0000000 --- a/src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php +++ /dev/null @@ -1,30 +0,0 @@ -isStrictMode(), $context->shouldCollectErrors(), + (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false), ); } @@ -50,6 +52,14 @@ public function withErrorCollection(bool $collect): self return $clone; } + public function withEmptyStringAsNull(bool $enabled): self + { + $clone = clone $this; + $clone->emptyStringIsNull = $enabled; + + return $clone; + } + public function isStrictMode(): bool { return $this->strictMode; @@ -60,14 +70,20 @@ public function shouldCollectErrors(): bool return $this->collectErrors; } + public function shouldTreatEmptyStringAsNull(): bool + { + return $this->emptyStringIsNull; + } + /** * @return array */ public function toOptions(): array { return [ - MappingContext::OPTION_STRICT_MODE => $this->strictMode, - MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, ]; } } diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 8cf2959..510dd2d 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -13,13 +13,17 @@ use MagicSunday\JsonMapper\Exception\MappingException; +use function array_slice; +use function count; + /** * Represents the state shared while mapping JSON structures. */ final class MappingContext { - public const OPTION_STRICT_MODE = 'strict_mode'; - public const OPTION_COLLECT_ERRORS = 'collect_errors'; + public const OPTION_STRICT_MODE = 'strict_mode'; + public const OPTION_COLLECT_ERRORS = 'collect_errors'; + public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; /** * @var list @@ -161,4 +165,14 @@ public function getErrorRecords(): array { return $this->errorRecords; } + + public function getErrorCount(): int + { + return count($this->errorRecords); + } + + public function trimErrors(int $count): void + { + $this->errorRecords = array_slice($this->errorRecords, 0, $count); + } } diff --git a/src/JsonMapper/Exception/ReadonlyPropertyException.php b/src/JsonMapper/Exception/ReadonlyPropertyException.php new file mode 100644 index 0000000..48e5256 --- /dev/null +++ b/src/JsonMapper/Exception/ReadonlyPropertyException.php @@ -0,0 +1,28 @@ +extractor->getType($className, $propertyName); - if ($type instanceof UnionType) { - $type = $type->getTypes()[0]; - } - if ($type === null) { $type = $this->resolveFromReflection($className, $propertyName); } - $resolved = $type ?? $this->defaultType; + if ($type instanceof Type) { + $resolved = $this->normalizeType($type); + } else { + $resolved = $this->defaultType; + } $this->storeCachedType($className, $propertyName, $resolved); return $resolved; } + private function normalizeType(Type $type): Type + { + if ($type instanceof UnionType) { + return $this->normalizeUnionType($type); + } + + return $type; + } + /** * Returns a cached type if available. * @@ -154,8 +163,8 @@ private function resolveFromReflection(string $className, string $propertyName): } if ($reflectionType instanceof ReflectionUnionType) { + $types = []; $allowsNull = false; - $primary = null; foreach ($reflectionType->getTypes() as $innerType) { if (!$innerType instanceof ReflectionNamedType) { @@ -168,12 +177,24 @@ private function resolveFromReflection(string $className, string $propertyName): continue; } - $primary ??= $innerType; + $resolved = $this->createTypeFromNamedReflection($innerType); + + if ($resolved instanceof Type) { + $types[] = $resolved; + } } - if ($primary instanceof ReflectionNamedType) { - return $this->createTypeFromNamedReflection($primary, $allowsNull || $primary->allowsNull()); + if ($types === []) { + return $allowsNull ? Type::nullable($this->defaultType) : null; } + + $union = count($types) === 1 ? $types[0] : Type::union(...$types); + + if ($allowsNull) { + return Type::nullable($union); + } + + return $union; } return null; @@ -203,4 +224,37 @@ private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool return $resolved; } + + private function normalizeUnionType(UnionType $type): Type + { + $types = []; + $allowsNull = false; + + foreach ($type->getTypes() as $inner) { + if ($this->isNullType($inner)) { + $allowsNull = true; + + continue; + } + + $types[] = $this->normalizeType($inner); + } + + if ($types === []) { + return $allowsNull ? Type::nullable($this->defaultType) : $this->defaultType; + } + + $union = count($types) === 1 ? $types[0] : Type::union(...$types); + + if ($allowsNull) { + return Type::nullable($union); + } + + return $union; + } + + private function isNullType(Type $type): bool + { + return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; + } } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index cf0d1c5..74f65d7 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -19,6 +19,7 @@ use Traversable; use function assert; +use function filter_var; use function get_debug_type; use function is_array; use function is_bool; @@ -28,6 +29,12 @@ use function is_object; use function is_string; use function settype; +use function strtolower; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_FLOAT; +use const FILTER_VALIDATE_INT; /** * Converts scalar values to the requested builtin type. @@ -43,14 +50,79 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe { assert($type instanceof BuiltinType); - $this->guardCompatibility($value, $type, $context); + $normalized = $this->normalizeValue($value, $type); + + $this->guardCompatibility($normalized, $type, $context); + + if ($normalized === null) { + return null; + } - $converted = $value; + $converted = $normalized; settype($converted, $type->getTypeIdentifier()->value); return $converted; } + private function normalizeValue(mixed $value, BuiltinType $type): mixed + { + if ($value === null) { + return null; + } + + $identifier = $type->getTypeIdentifier()->value; + + if ($identifier === 'bool') { + if (is_string($value)) { + $normalized = strtolower(trim($value)); + + if ($normalized === '1' || $normalized === 'true') { + return true; + } + + if ($normalized === '0' || $normalized === 'false') { + return false; + } + } + + if (is_int($value)) { + if ($value === 0) { + return false; + } + + if ($value === 1) { + return true; + } + } + } + + if ($identifier === 'int' && is_string($value)) { + $filtered = filter_var(trim($value), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + if ($filtered !== null) { + return $filtered; + } + } + + if ($identifier === 'float' && is_string($value)) { + $filtered = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + + if ($filtered !== null) { + return $filtered; + } + } + + if ($identifier === 'int' && is_float($value)) { + return (int) $value; + } + + if ($identifier === 'float' && is_int($value)) { + return (float) $value; + } + + return $value; + } + private function guardCompatibility(mixed $value, BuiltinType $type, MappingContext $context): void { $identifier = $type->getTypeIdentifier(); diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php new file mode 100644 index 0000000..eaf08be --- /dev/null +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -0,0 +1,87 @@ +getClassName(); + + if ($className === '') { + return false; + } + + return is_a($className, DateTimeImmutable::class, true) || is_a($className, DateInterval::class, true); + } + + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + if (!($type instanceof ObjectType)) { + return $value; + } + + $className = $type->getClassName(); + + if ($value === null) { + if ($type->isNullable()) { + return null; + } + + throw new TypeMismatchException($context->getPath(), $className, 'null'); + } + + if (!is_string($value) && !is_int($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + if (is_a($className, DateInterval::class, true)) { + if (!is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + return new $className($value); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + } + + $formatted = is_int($value) ? '@' . $value : $value; + + try { + return new $className($formatted); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + } +} diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php new file mode 100644 index 0000000..97fd179 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -0,0 +1,80 @@ +getClassName(); + + if ($className === '') { + return false; + } + + if (!enum_exists($className)) { + return false; + } + + return is_a($className, BackedEnum::class, true); + } + + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + if (!($type instanceof ObjectType)) { + return $value; + } + + $className = $type->getClassName(); + + if ($value === null) { + if ($type->isNullable()) { + return null; + } + + throw new TypeMismatchException($context->getPath(), $className, 'null'); + } + + if (!is_int($value) && !is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + /** @var BackedEnum $enum */ + $enum = $className::from($value); + } catch (ValueError) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + return $enum; + } +} diff --git a/tests/Annotation/ReplacePropertyTest.php b/tests/Attribute/ReplacePropertyTest.php similarity index 97% rename from tests/Annotation/ReplacePropertyTest.php rename to tests/Attribute/ReplacePropertyTest.php index ae8be85..73c9bbe 100644 --- a/tests/Annotation/ReplacePropertyTest.php +++ b/tests/Attribute/ReplacePropertyTest.php @@ -9,7 +9,7 @@ declare(strict_types=1); -namespace MagicSunday\Test\Annotation; +namespace MagicSunday\Test\Attribute; use MagicSunday\Test\Classes\ReplacePropertyTestClass; use MagicSunday\Test\TestCase; diff --git a/tests/Classes/DateTimeHolder.php b/tests/Classes/DateTimeHolder.php new file mode 100644 index 0000000..cb42c41 --- /dev/null +++ b/tests/Classes/DateTimeHolder.php @@ -0,0 +1,22 @@ + - * - * @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue */ + #[ReplaceNullWithDefaultValue] public array $array = []; } diff --git a/tests/Classes/NullableStringHolder.php b/tests/Classes/NullableStringHolder.php new file mode 100644 index 0000000..db4f672 --- /dev/null +++ b/tests/Classes/NullableStringHolder.php @@ -0,0 +1,17 @@ + * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ - * - * @ReplaceProperty("type", replaces="ftype") - * @ReplaceProperty("name", replaces="super-cryptic-name") */ +#[ReplaceProperty('type', replaces: 'ftype')] +#[ReplaceProperty('name', replaces: 'super-cryptic-name')] class ReplacePropertyTestClass { /** diff --git a/tests/Classes/ScalarHolder.php b/tests/Classes/ScalarHolder.php new file mode 100644 index 0000000..d49afc9 --- /dev/null +++ b/tests/Classes/ScalarHolder.php @@ -0,0 +1,21 @@ +isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); } #[Test] @@ -48,22 +49,34 @@ public function itSupportsTogglingErrorCollection(): void self::assertFalse($configuration->shouldCollectErrors()); } + #[Test] + public function itSupportsEmptyStringConfiguration(): void + { + $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); + + self::assertTrue($configuration->shouldTreatEmptyStringAsNull()); + self::assertTrue($configuration->withEmptyStringAsNull(true)->shouldTreatEmptyStringAsNull()); + } + #[Test] public function itDerivesFromContext(): void { $context = new MappingContext([], [ MappingContext::OPTION_STRICT_MODE => true, MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ]); $configuration = MappingConfiguration::fromContext($context); self::assertTrue($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); self::assertSame( [ MappingContext::OPTION_STRICT_MODE => true, MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ], $configuration->toOptions(), ); diff --git a/tests/JsonMapper/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php index 793138d..e53132c 100644 --- a/tests/JsonMapper/JsonMapperErrorHandlingTest.php +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -14,10 +14,12 @@ use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Exception\CollectionMappingException; use MagicSunday\JsonMapper\Exception\MissingPropertyException; +use MagicSunday\JsonMapper\Exception\ReadonlyPropertyException; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\Person; +use MagicSunday\Test\Classes\ReadonlyEntity; use MagicSunday\Test\Classes\Simple; use MagicSunday\Test\TestCase; use PHPUnit\Framework\Attributes\Test; @@ -175,6 +177,25 @@ public function itCollectsNestedErrorsAcrossObjectGraphs(): void self::assertInstanceOf(UnknownPropertyException::class, $errorsByPath['$.simple.unknown']->getException()); } + #[Test] + public function itReportsReadonlyPropertyViolations(): void + { + $result = $this->getJsonMapper() + ->mapWithReport([ + 'id' => 'changed', + ], ReadonlyEntity::class); + + $entity = $result->getValue(); + + self::assertInstanceOf(ReadonlyEntity::class, $entity); + self::assertSame('initial', $entity->id); + + $errors = $result->getReport()->getErrors(); + self::assertCount(1, $errors); + self::assertInstanceOf(ReadonlyPropertyException::class, $errors[0]->getException()); + self::assertSame('Readonly property ' . ReadonlyEntity::class . '::id cannot be written at $.id.', $errors[0]->getMessage()); + } + #[Test] public function itThrowsOnInvalidNestedCollectionEntriesInStrictMode(): void { diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index e2d8f80..b22f076 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -11,6 +11,8 @@ namespace MagicSunday\Test; +use DateInterval; +use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\ClassMap\CollectionSource; use MagicSunday\Test\Classes\ClassMap\CollectionTarget; @@ -18,14 +20,20 @@ use MagicSunday\Test\Classes\ClassMap\TargetItem; use MagicSunday\Test\Classes\Collection; use MagicSunday\Test\Classes\CustomConstructor; +use MagicSunday\Test\Classes\DateTimeHolder; +use MagicSunday\Test\Classes\EnumHolder; use MagicSunday\Test\Classes\Initialized; use MagicSunday\Test\Classes\MapPlainArrayKeyValueClass; use MagicSunday\Test\Classes\MultidimensionalArray; +use MagicSunday\Test\Classes\NullableStringHolder; use MagicSunday\Test\Classes\Person; use MagicSunday\Test\Classes\PlainArrayClass; +use MagicSunday\Test\Classes\ScalarHolder; use MagicSunday\Test\Classes\Simple; +use MagicSunday\Test\Classes\UnionHolder; use MagicSunday\Test\Classes\VariadicSetterClass; use MagicSunday\Test\Classes\VipPerson; +use MagicSunday\Test\Fixtures\Enum\SampleStatus; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use stdClass; @@ -528,11 +536,11 @@ public function mapInitialized(): void } /** - * Tests mapping of default values using @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue - * annotation in case JSON contains NULL. + * Tests mapping of default values using #[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] + * when the JSON payload contains null values. */ #[Test] - public function mapNullToDefaultValueUsingAnnotation(): void + public function mapNullToDefaultValueUsingAttribute(): void { $result = $this->getJsonMapper() ->map( @@ -758,4 +766,107 @@ public function mappingCollectionElementsUsingClassMap(): void self::assertInstanceOf(CollectionTarget::class, $result); self::assertContainsOnlyInstancesOf(TargetItem::class, $result); } + + #[Test] + public function mapBackedEnumFromString(): void + { + $result = $this->getJsonMapper() + ->map(['status' => 'active'], EnumHolder::class); + + self::assertInstanceOf(EnumHolder::class, $result); + self::assertSame(SampleStatus::Active, $result->status); + } + + #[Test] + public function mapUnionTypeWithNumericString(): void + { + $result = $this->getJsonMapper() + ->map([ + 'value' => '42', + 'fallback' => 'hello', + ], UnionHolder::class); + + self::assertInstanceOf(UnionHolder::class, $result); + self::assertSame(42, $result->value); + self::assertSame('hello', $result->fallback); + } + + #[Test] + public function mapUnionTypeWithTextualValue(): void + { + $result = $this->getJsonMapper() + ->map([ + 'value' => 'oops', + 'fallback' => 99, + ], UnionHolder::class); + + self::assertInstanceOf(UnionHolder::class, $result); + self::assertSame('oops', $result->value); + self::assertSame(99, $result->fallback); + } + + #[Test] + public function mapDateTimeAndIntervalValues(): void + { + $result = $this->getJsonMapper() + ->map([ + 'createdAt' => '2024-04-01T12:00:00+00:00', + 'timeout' => 'PT15M', + ], DateTimeHolder::class); + + self::assertInstanceOf(DateTimeHolder::class, $result); + self::assertSame('2024-04-01T12:00:00+00:00', $result->createdAt->format('c')); + self::assertInstanceOf(DateInterval::class, $result->timeout); + self::assertSame(15, $result->timeout->i); + } + + #[Test] + public function mapScalarShorthandValues(): void + { + $result = $this->getJsonMapper() + ->map([ + 'intValue' => '42', + 'floatValue' => '3.14', + 'boolValue' => '1', + ], ScalarHolder::class); + + self::assertInstanceOf(ScalarHolder::class, $result); + self::assertSame(42, $result->intValue); + self::assertSame(3.14, $result->floatValue); + self::assertTrue($result->boolValue); + } + + #[Test] + public function mapScalarZeroStringToFalse(): void + { + $result = $this->getJsonMapper() + ->map([ + 'intValue' => '0', + 'floatValue' => '0', + 'boolValue' => '0', + ], ScalarHolder::class); + + self::assertInstanceOf(ScalarHolder::class, $result); + self::assertSame(0, $result->intValue); + self::assertSame(0.0, $result->floatValue); + self::assertFalse($result->boolValue); + } + + #[Test] + public function mapEmptyStringToNullWhenEnabled(): void + { + $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); + + $result = $this->getJsonMapper() + ->map( + ['value' => ''], + NullableStringHolder::class, + null, + null, + $configuration, + ); + + self::assertInstanceOf(NullableStringHolder::class, $result); + self::assertNull($result->value); + } } From 1527180618164a07ced23da91ff376e1a8631c8f Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 23:20:47 +0100 Subject: [PATCH 06/46] Support generic collection mapping --- src/JsonMapper.php | 78 ++++++++++++---- .../CollectionDocBlockTypeResolver.php | 93 +++++++++++++++++++ .../Collection/CollectionFactory.php | 2 +- .../Collection/CollectionFactoryInterface.php | 43 +++++++++ .../CollectionValueConversionStrategy.php | 4 +- tests/Classes/BaseCollection.php | 25 +++++ tests/Classes/Collection.php | 4 +- .../MappingConfigurationTest.php | 8 +- tests/JsonMapperTest.php | 23 +++++ 9 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php create mode 100644 src/JsonMapper/Collection/CollectionFactoryInterface.php create mode 100644 tests/Classes/BaseCollection.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 675b096..88d83cc 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -15,7 +15,9 @@ use InvalidArgumentException; use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; use MagicSunday\JsonMapper\Attribute\ReplaceProperty; +use MagicSunday\JsonMapper\Collection\CollectionDocBlockTypeResolver; use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; @@ -52,6 +54,7 @@ use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\TemplateType; use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; use Traversable; @@ -93,7 +96,9 @@ class JsonMapper private ValueConverter $valueConverter; - private CollectionFactory $collectionFactory; + private CollectionFactoryInterface $collectionFactory; + + private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; private CustomTypeRegistry $customTypeRegistry; @@ -110,11 +115,12 @@ public function __construct( array $classMap = [], ?CacheItemPoolInterface $typeCache = null, ) { - $this->typeResolver = new TypeResolver($extractor, $typeCache); - $this->classResolver = new ClassResolver($classMap); - $this->customTypeRegistry = new CustomTypeRegistry(); - $this->valueConverter = new ValueConverter(); - $this->collectionFactory = new CollectionFactory( + $this->typeResolver = new TypeResolver($extractor, $typeCache); + $this->classResolver = new ClassResolver($classMap); + $this->customTypeRegistry = new CustomTypeRegistry(); + $this->collectionDocBlockTypeResolver = new CollectionDocBlockTypeResolver(); + $this->valueConverter = new ValueConverter(); + $this->collectionFactory = new CollectionFactory( $this->valueConverter, $this->classResolver, function (string $className, ?array $arguments): object { @@ -198,20 +204,60 @@ public function map( } } - if ($className === null) { - return $json; - } - - /** @var class-string $resolvedClassName */ - $resolvedClassName = $this->classResolver->resolve($className, $json, $context); + $resolvedClassName = $className === null + ? null + : $this->classResolver->resolve($className, $json, $context); - /** @var class-string|null $resolvedCollectionClassName */ $resolvedCollectionClassName = $collectionClassName === null ? null : $this->classResolver->resolve($collectionClassName, $json, $context); $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); + /** @var Type|null $collectionValueType */ + $collectionValueType = null; + + if ($resolvedCollectionClassName !== null) { + if ($resolvedClassName !== null) { + $collectionValueType = new ObjectType($resolvedClassName); + } else { + $docBlockCollectionType = $this->collectionDocBlockTypeResolver->resolve($resolvedCollectionClassName); + + if (!$docBlockCollectionType instanceof CollectionType) { + throw new InvalidArgumentException(sprintf( + 'Unable to resolve the element type for collection [%s]. Define an "@extends" annotation such as "@extends %s".', + $resolvedCollectionClassName, + $resolvedCollectionClassName, + )); + } + + $collectionValueType = $docBlockCollectionType->getCollectionValueType(); + + if ($collectionValueType instanceof TemplateType) { + throw new InvalidArgumentException(sprintf( + 'Unable to resolve the element type for collection [%s]. Please provide a concrete class in the "@extends" annotation.', + $resolvedCollectionClassName, + )); + } + } + } + + $isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType !== null; + + if ($isGenericCollectionMapping) { + if ($resolvedCollectionClassName === null) { + throw new InvalidArgumentException('A collection class name must be provided when mapping without an element class.'); + } + + $collection = $this->collectionFactory->mapIterable($json, $collectionValueType, $context); + + return $this->makeInstance($resolvedCollectionClassName, $collection); + } + + if ($resolvedClassName === null) { + return $json; + } + if (!is_array($json) && !is_object($json)) { return $this->makeInstance($resolvedClassName); } @@ -220,7 +266,7 @@ public function map( ($resolvedCollectionClassName !== null) && $this->isIterableWithArraysOrObjects($json) ) { - $collection = $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); + $collection = $this->collectionFactory->mapIterable($json, $collectionValueType ?? new ObjectType($resolvedClassName), $context); return $this->makeInstance($resolvedCollectionClassName, $collection); } @@ -731,9 +777,9 @@ private function isIterableWithArraysOrObjects(mixed $json): bool /** * Assert that the given classes exist. */ - private function assertClassesExists(string $className, ?string $collectionClassName = null): void + private function assertClassesExists(?string $className, ?string $collectionClassName = null): void { - if (!class_exists($className)) { + if ($className !== null && !class_exists($className)) { throw new InvalidArgumentException(sprintf('Class [%s] does not exist', $className)); } diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php new file mode 100644 index 0000000..63a08e2 --- /dev/null +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -0,0 +1,93 @@ +docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); + $this->contextFactory = $contextFactory ?? new ContextFactory(); + $this->phpDocTypeHelper = $phpDocTypeHelper ?? new PhpDocTypeHelper(); + } + + /** + * Attempts to resolve a {@see CollectionType} from the collection class PHPDoc. + * + * @param class-string $collectionClassName + */ + public function resolve(string $collectionClassName): ?CollectionType + { + $reflectionClass = new ReflectionClass($collectionClassName); + $docComment = $reflectionClass->getDocComment(); + + if ($docComment === false) { + return null; + } + + $context = $this->contextFactory->createFromReflector($reflectionClass); + $docBlock = $this->docBlockFactory->create($docComment, $context); + + foreach (['extends', 'implements'] as $tagName) { + foreach ($docBlock->getTagsByName($tagName) as $tag) { + if (!$tag instanceof TagWithType) { + continue; + } + + $type = $tag->getType(); + + if (!$type instanceof DocType) { + continue; + } + + $resolved = $this->phpDocTypeHelper->getType($type); + + if ($resolved instanceof CollectionType) { + return $resolved; + } + } + } + + return null; + } +} diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index c37b69f..014feaa 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -32,7 +32,7 @@ /** * Creates collections and hydrates wrapping collection classes. */ -final readonly class CollectionFactory +final readonly class CollectionFactory implements CollectionFactoryInterface { /** * @param Closure(class-string, array|null):object $instantiator diff --git a/src/JsonMapper/Collection/CollectionFactoryInterface.php b/src/JsonMapper/Collection/CollectionFactoryInterface.php new file mode 100644 index 0000000..bfc3d6b --- /dev/null +++ b/src/JsonMapper/Collection/CollectionFactoryInterface.php @@ -0,0 +1,43 @@ +|null + */ + public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array; + + /** + * Builds a collection based on the specified collection type description. + * + * @param CollectionType $type The collection type metadata extracted from PHPStan/Psalm annotations. + * + * @return array|object|null + */ + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed; +} diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index 6fdff27..5227c83 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -11,7 +11,7 @@ namespace MagicSunday\JsonMapper\Value\Strategy; -use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; use MagicSunday\JsonMapper\Context\MappingContext; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -24,7 +24,7 @@ final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface { public function __construct( - private CollectionFactory $collectionFactory, + private CollectionFactoryInterface $collectionFactory, ) { } diff --git a/tests/Classes/BaseCollection.php b/tests/Classes/BaseCollection.php new file mode 100644 index 0000000..510659d --- /dev/null +++ b/tests/Classes/BaseCollection.php @@ -0,0 +1,25 @@ + + * @license https://opensource.org/licenses/MIT + * @link https://github.com/magicsunday/jsonmapper/ + * + * @extends Collection + */ +final class BaseCollection extends Collection +{ +} diff --git a/tests/Classes/Collection.php b/tests/Classes/Collection.php index 0881d65..b1fe2f3 100644 --- a/tests/Classes/Collection.php +++ b/tests/Classes/Collection.php @@ -24,7 +24,9 @@ * @template TKey of array-key * @template TValue * - * @implements ArrayAccess + * @extends ArrayObject + * + * @implements ArrayAccess */ class Collection extends ArrayObject implements ArrayAccess { diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php index b398c63..943af84 100644 --- a/tests/JsonMapper/Configuration/MappingConfigurationTest.php +++ b/tests/JsonMapper/Configuration/MappingConfigurationTest.php @@ -62,8 +62,8 @@ public function itSupportsEmptyStringConfiguration(): void public function itDerivesFromContext(): void { $context = new MappingContext([], [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ]); @@ -74,8 +74,8 @@ public function itDerivesFromContext(): void self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); self::assertSame( [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ], $configuration->toOptions(), diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index b22f076..27b4294 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -14,6 +14,7 @@ use DateInterval; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\Test\Classes\Base; +use MagicSunday\Test\Classes\BaseCollection; use MagicSunday\Test\Classes\ClassMap\CollectionSource; use MagicSunday\Test\Classes\ClassMap\CollectionTarget; use MagicSunday\Test\Classes\ClassMap\SourceItem; @@ -89,6 +90,28 @@ public function mapArrayOrCollection(string $jsonString): void self::assertSame('Item 2', $result[1]->name); } + /** + * Tests mapping a collection using a generic @extends annotation. + */ + #[Test] + public function mapCollectionUsingDocBlockExtends(): void + { + $result = $this->getJsonMapper() + ->map( + $this->getJsonAsArray(Provider\DataProvider::mapCollectionJson()), + null, + BaseCollection::class + ); + + self::assertInstanceOf(BaseCollection::class, $result); + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(Base::class, $result); + self::assertInstanceOf(Base::class, $result[0]); + self::assertSame('Item 1', $result[0]->name); + self::assertInstanceOf(Base::class, $result[1]); + self::assertSame('Item 2', $result[1]->name); + } + /** * Tests mapping an array or collection of objects. */ From 526ebe36d3676cd54b2ae37d3d59d3cf75c6cd02 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 23:32:58 +0100 Subject: [PATCH 07/46] Add extensible type handler support --- README.md | 31 +++++---- src/JsonMapper.php | 18 ++++- src/JsonMapper/Value/ClosureTypeHandler.php | 67 +++++++++++++++++++ src/JsonMapper/Value/CustomTypeRegistry.php | 47 +++++++------ .../CustomTypeValueConversionStrategy.php | 9 +-- src/JsonMapper/Value/TypeHandlerInterface.php | 40 +++++++++++ .../Value/CustomTypeRegistryTest.php | 43 +++++++++++- tests/JsonMapperTest.php | 43 ++++++------ 8 files changed, 233 insertions(+), 65 deletions(-) create mode 100644 src/JsonMapper/Value/ClosureTypeHandler.php create mode 100644 src/JsonMapper/Value/TypeHandlerInterface.php diff --git a/README.md b/README.md index 6e53d9e..ad81123 100644 --- a/README.md +++ b/README.md @@ -136,24 +136,31 @@ $mapper = new \MagicSunday\JsonMapper( To handle custom or special types of objects, add them to the mapper. For instance to perform special treatment if an object of type Bar should be mapped: + +You may alternatively implement `\MagicSunday\JsonMapper\Value\TypeHandlerInterface` to package reusable handlers. + ```php -$mapper->addType( - Bar::class, - /** @var mixed $value JSON data */ - static function ($value): ?Bar { - return $value ? new Bar($value['name']) : null; - } +$mapper->addTypeHandler( + new \MagicSunday\JsonMapper\Value\ClosureTypeHandler( + Bar::class, + /** @var mixed $value JSON data */ + static function ($value): ?Bar { + return $value ? new Bar($value['name']) : null; + }, + ), ); ``` or add a handler to map DateTime values: ```php -$mapper->addType( - \DateTime::class, - /** @var mixed $value JSON data */ - static function ($value): ?\DateTime { - return $value ? new \DateTime($value) : null; - } +$mapper->addTypeHandler( + new \MagicSunday\JsonMapper\Value\ClosureTypeHandler( + \DateTime::class, + /** @var mixed $value JSON data */ + static function ($value): ?\DateTime { + return $value ? new \DateTime($value) : null; + }, + ), ); ``` diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 88d83cc..abb9997 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -30,6 +30,7 @@ use MagicSunday\JsonMapper\Report\MappingResult; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Type\TypeResolver; +use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\JsonMapper\Value\CustomTypeRegistry; use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\CollectionValueConversionStrategy; @@ -39,6 +40,7 @@ use MagicSunday\JsonMapper\Value\Strategy\NullValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; +use MagicSunday\JsonMapper\Value\TypeHandlerInterface; use MagicSunday\JsonMapper\Value\ValueConverter; use Psr\Cache\CacheItemPoolInterface; use ReflectionAttribute; @@ -152,11 +154,23 @@ function (mixed $value, string $resolvedClass, MappingContext $context): mixed { } /** - * Add a custom type. + * Registers a custom type handler. + */ + public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper + { + $this->customTypeRegistry->registerHandler($handler); + + return $this; + } + + /** + * Registers a custom type using a closure-based handler. + * + * @deprecated Use addTypeHandler() with a TypeHandlerInterface implementation instead. */ public function addType(string $type, Closure $closure): JsonMapper { - $this->customTypeRegistry->register($type, $closure); + $this->customTypeRegistry->registerHandler(new ClosureTypeHandler($type, $closure)); return $this; } diff --git a/src/JsonMapper/Value/ClosureTypeHandler.php b/src/JsonMapper/Value/ClosureTypeHandler.php new file mode 100644 index 0000000..741026e --- /dev/null +++ b/src/JsonMapper/Value/ClosureTypeHandler.php @@ -0,0 +1,67 @@ +converter = $this->normalizeConverter($converter); + } + + public function supports(Type $type, mixed $value): bool + { + if (!$type instanceof ObjectType) { + return false; + } + + return $type->getClassName() === $this->className; + } + + public function convert(Type $type, mixed $value, MappingContext $context): mixed + { + if (!$this->supports($type, $value)) { + throw new LogicException(sprintf('Handler does not support type %s.', $type::class)); + } + + return ($this->converter)($value, $context); + } + + /** + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + */ + private function normalizeConverter(callable $converter): Closure + { + $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); + $reflection = new ReflectionFunction($closure); + + if ($reflection->getNumberOfParameters() >= 2) { + return $closure; + } + + return static fn (mixed $value, MappingContext $context): mixed => $closure($value); + } +} diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index 84a6174..7b99dd8 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -11,21 +11,21 @@ namespace MagicSunday\JsonMapper\Value; -use Closure; +use LogicException; use MagicSunday\JsonMapper\Context\MappingContext; -use ReflectionFunction; +use Symfony\Component\TypeInfo\Type; -use function array_key_exists; +use function sprintf; /** - * Stores custom conversion handlers keyed by class name. + * Stores custom conversion handlers. */ final class CustomTypeRegistry { /** - * @var array + * @var list */ - private array $converters = []; + private array $handlers = []; /** * Registers the converter for the provided class name. @@ -34,37 +34,42 @@ final class CustomTypeRegistry */ public function register(string $className, callable $converter): void { - $this->converters[$className] = $this->normalizeConverter($converter); + $this->registerHandler(new ClosureTypeHandler($className, $converter)); } /** - * Returns TRUE if a converter for the class exists. + * Registers a custom type handler. */ - public function has(string $className): bool + public function registerHandler(TypeHandlerInterface $handler): void { - return array_key_exists($className, $this->converters); + $this->handlers[] = $handler; } /** - * Executes the converter for the class. + * Returns TRUE if a handler for the type exists. */ - public function convert(string $className, mixed $value, MappingContext $context): mixed + public function supports(Type $type, mixed $value): bool { - return $this->converters[$className]($value, $context); + foreach ($this->handlers as $handler) { + if ($handler->supports($type, $value)) { + return true; + } + } + + return false; } /** - * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + * Executes the converter for the class. */ - private function normalizeConverter(callable $converter): Closure + public function convert(Type $type, mixed $value, MappingContext $context): mixed { - $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); - $reflection = new ReflectionFunction($closure); - - if ($reflection->getNumberOfParameters() >= 2) { - return $closure; + foreach ($this->handlers as $handler) { + if ($handler->supports($type, $value)) { + return $handler->convert($type, $value, $context); + } } - return static fn (mixed $value, MappingContext $context): mixed => $closure($value); + throw new LogicException(sprintf('No custom type handler registered for %s.', $type::class)); } } diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php index 5a9c5fe..c408a85 100644 --- a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -14,9 +14,6 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Value\CustomTypeRegistry; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; - -use function assert; /** * Handles conversion of registered custom types. @@ -30,13 +27,11 @@ public function __construct( public function supports(mixed $value, Type $type, MappingContext $context): bool { - return ($type instanceof ObjectType) && $this->registry->has($type->getClassName()); + return $this->registry->supports($type, $value); } public function convert(mixed $value, Type $type, MappingContext $context): mixed { - assert($type instanceof ObjectType); - - return $this->registry->convert($type->getClassName(), $value, $context); + return $this->registry->convert($type, $value, $context); } } diff --git a/src/JsonMapper/Value/TypeHandlerInterface.php b/src/JsonMapper/Value/TypeHandlerInterface.php new file mode 100644 index 0000000..a3b0230 --- /dev/null +++ b/src/JsonMapper/Value/TypeHandlerInterface.php @@ -0,0 +1,40 @@ +register('Foo', static fn (mixed $value): array => (array) $value); $context = new MappingContext([]); + $type = new ObjectType('Foo'); - self::assertTrue($registry->has('Foo')); - self::assertSame(['bar' => 'baz'], $registry->convert('Foo', ['bar' => 'baz'], $context)); + self::assertTrue($registry->supports($type, ['bar' => 'baz'])); + self::assertSame(['bar' => 'baz'], $registry->convert($type, ['bar' => 'baz'], $context)); } #[Test] @@ -44,8 +50,39 @@ public function itPassesContextToConverters(): void }); $context = new MappingContext([]); - $registry->convert('Foo', ['payload'], $context); + $type = new ObjectType('Foo'); + $registry->convert($type, ['payload'], $context); self::assertSame(['called'], $context->getErrors()); } + + #[Test] + public function itSupportsCustomHandlers(): void + { + $registry = new CustomTypeRegistry(); + $registry->registerHandler(new class implements TypeHandlerInterface { + public function supports(\Symfony\Component\TypeInfo\Type $type, mixed $value): bool + { + return $type instanceof ObjectType && $type->getClassName() === 'Foo'; + } + + public function convert(\Symfony\Component\TypeInfo\Type $type, mixed $value, MappingContext $context): mixed + { + if (!is_string($value)) { + throw new InvalidArgumentException('Expected string value.'); + } + + $context->addError('converted'); + + return 'handled-' . $value; + } + }); + + $context = new MappingContext([]); + $type = new ObjectType('Foo'); + + self::assertTrue($registry->supports($type, 'value')); + self::assertSame('handled-value', $registry->convert($type, 'value', $context)); + self::assertSame(['converted'], $context->getErrors()); + } } diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index 27b4294..cdaa998 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -13,6 +13,7 @@ use DateInterval; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\BaseCollection; use MagicSunday\Test\Classes\ClassMap\CollectionSource; @@ -250,27 +251,29 @@ public static function mapCustomTypeJsonDataProvider(): array public function mapCustomType(string $jsonString): void { $result = $this->getJsonMapper() - ->addType( - CustomConstructor::class, - static function (mixed $value): ?CustomConstructor { - if ( - is_array($value) - && isset($value['name']) - && is_string($value['name']) - ) { - return new CustomConstructor($value['name']); - } - - if ( - ($value instanceof stdClass) - && property_exists($value, 'name') - && is_string($value->name) - ) { - return new CustomConstructor($value->name); - } + ->addTypeHandler( + new ClosureTypeHandler( + CustomConstructor::class, + static function (mixed $value): ?CustomConstructor { + if ( + is_array($value) + && isset($value['name']) + && is_string($value['name']) + ) { + return new CustomConstructor($value['name']); + } - return null; - } + if ( + ($value instanceof stdClass) + && property_exists($value, 'name') + && is_string($value->name) + ) { + return new CustomConstructor($value->name); + } + + return null; + }, + ), ) ->map( $this->getJsonAsArray($jsonString), From 9faf61bb28bea3b3e0bf6db5a0ab463061d80bec Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 00:03:29 +0100 Subject: [PATCH 08/46] Refine object conversion guards and CPD command --- composer.json | 2 +- src/JsonMapper.php | 38 +++- .../Collection/CollectionFactory.php | 4 + .../Configuration/JsonMapperConfig.php | 167 ++++++++++++++++++ .../Configuration/MappingConfiguration.php | 75 +++++++- src/JsonMapper/Context/MappingContext.php | 39 +++- .../DateTimeValueConversionStrategy.php | 87 ++++----- .../Strategy/EnumValueConversionStrategy.php | 56 +++--- .../ObjectTypeConversionGuardTrait.php | 77 ++++++++ .../ObjectValueConversionStrategy.php | 10 +- .../Configuration/JsonMapperConfigTest.php | 79 +++++++++ .../MappingConfigurationTest.php | 45 ++++- .../JsonMapper/Context/MappingContextTest.php | 16 ++ tests/JsonMapperTest.php | 88 +++++++++ tests/TestCase.php | 6 +- 15 files changed, 691 insertions(+), 98 deletions(-) create mode 100644 src/JsonMapper/Configuration/JsonMapperConfig.php create mode 100644 src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php create mode 100644 tests/JsonMapper/Configuration/JsonMapperConfigTest.php diff --git a/composer.json b/composer.json index 9d700ef..5d02e77 100644 --- a/composer.json +++ b/composer.json @@ -76,7 +76,7 @@ "@ci:rector --dry-run" ], "ci:test:php:cpd": [ - "npx -y jscpd@latest --config .jscpd.json" + "npx jscpd --config .jscpd.json" ], "ci:test:php:unit": [ "phpunit --configuration phpunit.xml" diff --git a/src/JsonMapper.php b/src/JsonMapper.php index abb9997..f2b08aa 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -18,6 +18,7 @@ use MagicSunday\JsonMapper\Collection\CollectionDocBlockTypeResolver; use MagicSunday\JsonMapper\Collection\CollectionFactory; use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; @@ -104,6 +105,8 @@ class JsonMapper private CustomTypeRegistry $customTypeRegistry; + private JsonMapperConfig $config; + /** * @param array $classMap * @param CacheItemPoolInterface|null $typeCache @@ -116,7 +119,9 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, + ?JsonMapperConfig $config = null, ) { + $this->config = $config ?? new JsonMapperConfig(); $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); $this->customTypeRegistry = new CustomTypeRegistry(); @@ -208,8 +213,8 @@ public function map( ?MappingConfiguration $configuration = null, ): mixed { if ($context === null) { - $configuration ??= MappingConfiguration::lenient(); - $context = new MappingContext($json, $configuration->toOptions()); + $configuration = $configuration ?? $this->createDefaultConfiguration(); + $context = new MappingContext($json, $configuration->toOptions()); } else { if ($configuration === null) { $configuration = MappingConfiguration::fromContext($context); @@ -317,6 +322,10 @@ public function map( } if (!in_array($normalizedProperty, $properties, true)) { + if ($configuration->shouldIgnoreUnknownProperties()) { + return; + } + $this->handleMappingException( new UnknownPropertyException($propertyContext->getPath(), $normalizedProperty, $resolvedClassName), $propertyContext, @@ -385,13 +394,36 @@ public function mapWithReport( ?string $collectionClassName = null, ?MappingConfiguration $configuration = null, ): MappingResult { - $configuration = ($configuration ?? MappingConfiguration::lenient())->withErrorCollection(true); + $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); $context = new MappingContext($json, $configuration->toOptions()); $value = $this->map($json, $className, $collectionClassName, $context, $configuration); return new MappingResult($value, new MappingReport($context->getErrorRecords())); } + private function createDefaultConfiguration(): MappingConfiguration + { + $configuration = $this->config->isStrictMode() + ? MappingConfiguration::strict() + : MappingConfiguration::lenient(); + + if ($this->config->shouldIgnoreUnknownProperties()) { + $configuration = $configuration->withIgnoreUnknownProperties(true); + } + + if ($this->config->shouldTreatNullAsEmptyCollection()) { + $configuration = $configuration->withTreatNullAsEmptyCollection(true); + } + + $configuration = $configuration->withDefaultDateFormat($this->config->getDefaultDateFormat()); + + if ($this->config->shouldAllowScalarToObjectCasting()) { + $configuration = $configuration->withScalarToObjectCasting(true); + } + + return $configuration; + } + /** * @param class-string $className * @param array $declaredProperties diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index 014feaa..a5ccfd7 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -52,6 +52,10 @@ public function __construct( public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array { if ($json === null) { + if ($context->shouldTreatNullAsEmptyCollection()) { + return []; + } + return null; } diff --git a/src/JsonMapper/Configuration/JsonMapperConfig.php b/src/JsonMapper/Configuration/JsonMapperConfig.php new file mode 100644 index 0000000..807ae15 --- /dev/null +++ b/src/JsonMapper/Configuration/JsonMapperConfig.php @@ -0,0 +1,167 @@ + $data Configuration values indexed by property name + */ + public static function fromArray(array $data): self + { + $defaultDateFormat = $data['defaultDateFormat'] ?? DateTimeInterface::ATOM; + + if (!is_string($defaultDateFormat)) { + $defaultDateFormat = DateTimeInterface::ATOM; + } + + return new self( + (bool) ($data['strictMode'] ?? false), + (bool) ($data['ignoreUnknownProperties'] ?? false), + (bool) ($data['treatNullAsEmptyCollection'] ?? false), + $defaultDateFormat, + (bool) ($data['allowScalarToObjectCasting'] ?? false), + ); + } + + /** + * Serializes the configuration into an array representation. + * + * @return array + */ + public function toArray(): array + { + return [ + 'strictMode' => $this->strictMode, + 'ignoreUnknownProperties' => $this->ignoreUnknownProperties, + 'treatNullAsEmptyCollection' => $this->treatNullAsEmptyCollection, + 'defaultDateFormat' => $this->defaultDateFormat, + 'allowScalarToObjectCasting' => $this->allowScalarToObjectCasting, + ]; + } + + /** + * Indicates whether strict mode is enabled. + */ + public function isStrictMode(): bool + { + return $this->strictMode; + } + + /** + * Indicates whether unknown properties should be ignored during mapping. + */ + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + /** + * Indicates whether null collections should be treated as empty collections. + */ + public function shouldTreatNullAsEmptyCollection(): bool + { + return $this->treatNullAsEmptyCollection; + } + + /** + * Returns the default date format used by the mapper. + */ + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + /** + * Indicates whether scalar values should be cast to objects when possible. + */ + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + + /** + * Returns a copy with the strict mode flag toggled. + */ + public function withStrictMode(bool $enabled): self + { + $clone = clone $this; + $clone->strictMode = $enabled; + + return $clone; + } + + /** + * Returns a copy with the ignore-unknown-properties flag toggled. + */ + public function withIgnoreUnknownProperties(bool $enabled): self + { + $clone = clone $this; + $clone->ignoreUnknownProperties = $enabled; + + return $clone; + } + + /** + * Returns a copy with the treat-null-as-empty-collection flag toggled. + */ + public function withTreatNullAsEmptyCollection(bool $enabled): self + { + $clone = clone $this; + $clone->treatNullAsEmptyCollection = $enabled; + + return $clone; + } + + /** + * Returns a copy with a different default date format. + */ + public function withDefaultDateFormat(string $format): self + { + $clone = clone $this; + $clone->defaultDateFormat = $format; + + return $clone; + } + + /** + * Returns a copy with the scalar-to-object casting flag toggled. + */ + public function withScalarToObjectCasting(bool $enabled): self + { + $clone = clone $this; + $clone->allowScalarToObjectCasting = $enabled; + + return $clone; + } +} diff --git a/src/JsonMapper/Configuration/MappingConfiguration.php b/src/JsonMapper/Configuration/MappingConfiguration.php index 737a6fe..7998cbe 100644 --- a/src/JsonMapper/Configuration/MappingConfiguration.php +++ b/src/JsonMapper/Configuration/MappingConfiguration.php @@ -11,6 +11,7 @@ namespace MagicSunday\JsonMapper\Configuration; +use DateTimeInterface; use MagicSunday\JsonMapper\Context\MappingContext; /** @@ -22,12 +23,16 @@ public function __construct( private bool $strictMode = false, private bool $collectErrors = true, private bool $emptyStringIsNull = false, + private bool $ignoreUnknownProperties = false, + private bool $treatNullAsEmptyCollection = false, + private string $defaultDateFormat = DateTimeInterface::ATOM, + private bool $allowScalarToObjectCasting = false, ) { } public static function lenient(): self { - return new self(false, true); + return new self(); } public static function strict(): self @@ -41,6 +46,10 @@ public static function fromContext(MappingContext $context): self $context->isStrictMode(), $context->shouldCollectErrors(), (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false), + $context->shouldIgnoreUnknownProperties(), + $context->shouldTreatNullAsEmptyCollection(), + $context->getDefaultDateFormat(), + $context->shouldAllowScalarToObjectCasting(), ); } @@ -60,6 +69,38 @@ public function withEmptyStringAsNull(bool $enabled): self return $clone; } + public function withIgnoreUnknownProperties(bool $enabled): self + { + $clone = clone $this; + $clone->ignoreUnknownProperties = $enabled; + + return $clone; + } + + public function withTreatNullAsEmptyCollection(bool $enabled): self + { + $clone = clone $this; + $clone->treatNullAsEmptyCollection = $enabled; + + return $clone; + } + + public function withDefaultDateFormat(string $format): self + { + $clone = clone $this; + $clone->defaultDateFormat = $format; + + return $clone; + } + + public function withScalarToObjectCasting(bool $enabled): self + { + $clone = clone $this; + $clone->allowScalarToObjectCasting = $enabled; + + return $clone; + } + public function isStrictMode(): bool { return $this->strictMode; @@ -75,15 +116,39 @@ public function shouldTreatEmptyStringAsNull(): bool return $this->emptyStringIsNull; } + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + public function shouldTreatNullAsEmptyCollection(): bool + { + return $this->treatNullAsEmptyCollection; + } + + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + /** - * @return array + * @return array */ public function toOptions(): array { return [ - MappingContext::OPTION_STRICT_MODE => $this->strictMode, - MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => $this->ignoreUnknownProperties, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => $this->treatNullAsEmptyCollection, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => $this->defaultDateFormat, + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => $this->allowScalarToObjectCasting, ]; } } diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 510dd2d..b9ecf8b 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -11,19 +11,26 @@ namespace MagicSunday\JsonMapper\Context; +use DateTimeInterface; use MagicSunday\JsonMapper\Exception\MappingException; use function array_slice; use function count; +use function implode; +use function is_string; /** * Represents the state shared while mapping JSON structures. */ final class MappingContext { - public const OPTION_STRICT_MODE = 'strict_mode'; - public const OPTION_COLLECT_ERRORS = 'collect_errors'; - public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; + public const OPTION_STRICT_MODE = 'strict_mode'; + public const OPTION_COLLECT_ERRORS = 'collect_errors'; + public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; + public const OPTION_IGNORE_UNKNOWN_PROPERTIES = 'ignore_unknown_properties'; + public const OPTION_TREAT_NULL_AS_EMPTY_COLLECTION = 'treat_null_as_empty_collection'; + public const OPTION_DEFAULT_DATE_FORMAT = 'default_date_format'; + public const OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING = 'allow_scalar_to_object_casting'; /** * @var list @@ -128,6 +135,32 @@ public function isStrictMode(): bool return (bool) ($this->options[self::OPTION_STRICT_MODE] ?? false); } + public function shouldIgnoreUnknownProperties(): bool + { + return (bool) ($this->options[self::OPTION_IGNORE_UNKNOWN_PROPERTIES] ?? false); + } + + public function shouldTreatNullAsEmptyCollection(): bool + { + return (bool) ($this->options[self::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION] ?? false); + } + + public function getDefaultDateFormat(): string + { + $format = $this->options[self::OPTION_DEFAULT_DATE_FORMAT] ?? DateTimeInterface::ATOM; + + if (!is_string($format) || $format === '') { + return DateTimeInterface::ATOM; + } + + return $format; + } + + public function shouldAllowScalarToObjectCasting(): bool + { + return (bool) ($this->options[self::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING] ?? false); + } + /** * Returns all options. * diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php index eaf08be..077e318 100644 --- a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -13,11 +13,11 @@ use DateInterval; use DateTimeImmutable; +use DateTimeInterface; use Exception; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; use function get_debug_type; use function is_a; @@ -29,59 +29,60 @@ */ final class DateTimeValueConversionStrategy implements ValueConversionStrategyInterface { + use ObjectTypeConversionGuardTrait; + public function supports(mixed $value, Type $type, MappingContext $context): bool { - if (!($type instanceof ObjectType)) { - return false; - } + $objectType = $this->extractObjectType($type); - $className = $type->getClassName(); - - if ($className === '') { + if ($objectType === null) { return false; } + $className = $objectType->getClassName(); + return is_a($className, DateTimeImmutable::class, true) || is_a($className, DateInterval::class, true); } public function convert(mixed $value, Type $type, MappingContext $context): mixed { - if (!($type instanceof ObjectType)) { - return $value; - } - - $className = $type->getClassName(); - - if ($value === null) { - if ($type->isNullable()) { - return null; - } - - throw new TypeMismatchException($context->getPath(), $className, 'null'); - } - - if (!is_string($value) && !is_int($value)) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - if (is_a($className, DateInterval::class, true)) { - if (!is_string($value)) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - try { - return new $className($value); - } catch (Exception) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + return $this->convertObjectValue( + $type, + $context, + $value, + static function (string $className, mixed $value) use ($context) { + if (!is_string($value) && !is_int($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + if (is_a($className, DateInterval::class, true)) { + if (!is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + return new $className($value); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + } + + if (is_string($value)) { + $parsed = $className::createFromFormat($context->getDefaultDateFormat(), $value); + + if ($parsed instanceof DateTimeInterface) { + return $parsed; + } + } + + $formatted = is_int($value) ? '@' . $value : $value; + + try { + return new $className($formatted); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } } - } - - $formatted = is_int($value) ? '@' . $value : $value; - - try { - return new $className($formatted); - } catch (Exception) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } + ); } } diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php index 97fd179..0e7b553 100644 --- a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -15,7 +15,6 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; use ValueError; use function enum_exists; @@ -29,18 +28,18 @@ */ final class EnumValueConversionStrategy implements ValueConversionStrategyInterface { + use ObjectTypeConversionGuardTrait; + public function supports(mixed $value, Type $type, MappingContext $context): bool { - if (!($type instanceof ObjectType)) { - return false; - } - - $className = $type->getClassName(); + $objectType = $this->extractObjectType($type); - if ($className === '') { + if ($objectType === null) { return false; } + $className = $objectType->getClassName(); + if (!enum_exists($className)) { return false; } @@ -50,31 +49,24 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - if (!($type instanceof ObjectType)) { - return $value; - } - - $className = $type->getClassName(); - - if ($value === null) { - if ($type->isNullable()) { - return null; + return $this->convertObjectValue( + $type, + $context, + $value, + static function (string $className, mixed $value) use ($context) { + if (!is_int($value) && !is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + /** @var BackedEnum $enum */ + $enum = $className::from($value); + } catch (ValueError) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + return $enum; } - - throw new TypeMismatchException($context->getPath(), $className, 'null'); - } - - if (!is_int($value) && !is_string($value)) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - try { - /** @var BackedEnum $enum */ - $enum = $className::from($value); - } catch (ValueError) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - return $enum; + ); } } diff --git a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php new file mode 100644 index 0000000..0483ad6 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php @@ -0,0 +1,77 @@ +getClassName() === '') { + return null; + } + + return $type; + } + + /** + * Ensures null values comply with the target object's nullability. + */ + private function guardNullableValue(mixed $value, ObjectType $type, MappingContext $context): void + { + if ($value !== null) { + return; + } + + if ($type->isNullable()) { + return; + } + + throw new TypeMismatchException($context->getPath(), $type->getClassName(), 'null'); + } + + /** + * Executes the provided converter when a valid object type is available. + * + * @param callable(string, mixed): mixed $converter + */ + private function convertObjectValue(Type $type, MappingContext $context, mixed $value, callable $converter): mixed + { + $objectType = $this->extractObjectType($type); + + if ($objectType === null) { + return $value; + } + + $this->guardNullableValue($value, $objectType, $context); + + if ($value === null) { + return null; + } + + return $converter($objectType->getClassName(), $value); + } +} diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 1fcc88c..5640f46 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -52,11 +52,13 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe $resolvedClass = $this->classResolver->resolve($className, $value, $context); if (($value !== null) && !is_array($value) && !is_object($value)) { - $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); - $context->recordException($exception); + if (!$context->shouldAllowScalarToObjectCasting()) { + $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); + $context->recordException($exception); - if ($context->isStrictMode()) { - throw $exception; + if ($context->isStrictMode()) { + throw $exception; + } } } diff --git a/tests/JsonMapper/Configuration/JsonMapperConfigTest.php b/tests/JsonMapper/Configuration/JsonMapperConfigTest.php new file mode 100644 index 0000000..21bcdd9 --- /dev/null +++ b/tests/JsonMapper/Configuration/JsonMapperConfigTest.php @@ -0,0 +1,79 @@ +isStrictMode()); + self::assertFalse($config->shouldIgnoreUnknownProperties()); + self::assertFalse($config->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); + self::assertFalse($config->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itSupportsImmutableUpdates(): void + { + $config = new JsonMapperConfig(); + + $modified = $config + ->withStrictMode(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y H:i:s') + ->withScalarToObjectCasting(true); + + self::assertFalse($config->isStrictMode()); + self::assertFalse($config->shouldIgnoreUnknownProperties()); + self::assertFalse($config->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); + self::assertFalse($config->shouldAllowScalarToObjectCasting()); + + self::assertTrue($modified->isStrictMode()); + self::assertTrue($modified->shouldIgnoreUnknownProperties()); + self::assertTrue($modified->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y H:i:s', $modified->getDefaultDateFormat()); + self::assertTrue($modified->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itSerializesAndRestoresItself(): void + { + $config = (new JsonMapperConfig()) + ->withStrictMode(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y') + ->withScalarToObjectCasting(true); + + $restored = JsonMapperConfig::fromArray($config->toArray()); + + self::assertTrue($restored->isStrictMode()); + self::assertTrue($restored->shouldIgnoreUnknownProperties()); + self::assertTrue($restored->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $restored->getDefaultDateFormat()); + self::assertTrue($restored->shouldAllowScalarToObjectCasting()); + } +} diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php index 943af84..aab92cb 100644 --- a/tests/JsonMapper/Configuration/MappingConfigurationTest.php +++ b/tests/JsonMapper/Configuration/MappingConfigurationTest.php @@ -11,6 +11,7 @@ namespace MagicSunday\Test\JsonMapper\Configuration; +use DateTimeInterface; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use PHPUnit\Framework\Attributes\Test; @@ -29,6 +30,10 @@ public function itProvidesLenientDefaults(): void self::assertFalse($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); + self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); } #[Test] @@ -38,6 +43,7 @@ public function itEnablesStrictMode(): void self::assertTrue($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); } #[Test] @@ -58,13 +64,32 @@ public function itSupportsEmptyStringConfiguration(): void self::assertTrue($configuration->withEmptyStringAsNull(true)->shouldTreatEmptyStringAsNull()); } + #[Test] + public function itSupportsExtendedFlags(): void + { + $configuration = MappingConfiguration::lenient() + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y H:i:s') + ->withScalarToObjectCasting(true); + + self::assertTrue($configuration->shouldIgnoreUnknownProperties()); + self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y H:i:s', $configuration->getDefaultDateFormat()); + self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); + } + #[Test] public function itDerivesFromContext(): void { $context = new MappingContext([], [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, ]); $configuration = MappingConfiguration::fromContext($context); @@ -72,11 +97,19 @@ public function itDerivesFromContext(): void self::assertTrue($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertTrue($configuration->shouldIgnoreUnknownProperties()); + self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $configuration->getDefaultDateFormat()); + self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); self::assertSame( [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, ], $configuration->toOptions(), ); diff --git a/tests/JsonMapper/Context/MappingContextTest.php b/tests/JsonMapper/Context/MappingContextTest.php index dc8b5ce..c71339d 100644 --- a/tests/JsonMapper/Context/MappingContextTest.php +++ b/tests/JsonMapper/Context/MappingContextTest.php @@ -59,4 +59,20 @@ public function itExposesOptions(): void self::assertTrue($context->getOption('flag')); self::assertSame('fallback', $context->getOption('missing', 'fallback')); } + + #[Test] + public function itProvidesTypedOptionAccessors(): void + { + $context = new MappingContext(['root'], [ + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, + ]); + + self::assertTrue($context->shouldIgnoreUnknownProperties()); + self::assertTrue($context->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $context->getDefaultDateFormat()); + self::assertTrue($context->shouldAllowScalarToObjectCasting()); + } } diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index cdaa998..4ec3a31 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -12,7 +12,9 @@ namespace MagicSunday\Test; use DateInterval; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\BaseCollection; @@ -895,4 +897,90 @@ public function mapEmptyStringToNullWhenEnabled(): void self::assertInstanceOf(NullableStringHolder::class, $result); self::assertNull($result->value); } + + #[Test] + public function itAppliesConfiguredStrictModeByDefault(): void + { + $config = (new JsonMapperConfig())->withStrictMode(true); + + $this->expectException(UnknownPropertyException::class); + + $this->getJsonMapper([], $config)->map( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + ); + } + + #[Test] + public function itIgnoresUnknownPropertiesWhenConfigured(): void + { + $config = (new JsonMapperConfig())->withIgnoreUnknownProperties(true); + + $result = $this->getJsonMapper([], $config) + ->mapWithReport( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + ); + + self::assertInstanceOf(Person::class, $result->getValue()); + self::assertFalse($result->getReport()->hasErrors()); + } + + #[Test] + public function itTreatsNullCollectionsAsEmptyWhenConfigured(): void + { + $config = (new JsonMapperConfig())->withTreatNullAsEmptyCollection(true); + + $result = $this->getJsonMapper([], $config) + ->map( + [ + 'simpleArray' => null, + ], + Base::class, + ); + + self::assertInstanceOf(Base::class, $result); + self::assertSame([], $result->simpleArray); + } + + #[Test] + public function itUsesDefaultDateFormatFromConfiguration(): void + { + $config = (new JsonMapperConfig())->withDefaultDateFormat('d.m.Y H:i:s'); + + $result = $this->getJsonMapper([], $config) + ->map( + [ + 'createdAt' => '24.01.2024 18:45:00', + ], + DateTimeHolder::class, + ); + + self::assertInstanceOf(DateTimeHolder::class, $result); + self::assertSame('24.01.2024 18:45:00', $result->createdAt->format('d.m.Y H:i:s')); + } + + #[Test] + public function itAllowsScalarToObjectCastingWhenConfigured(): void + { + $config = (new JsonMapperConfig())->withScalarToObjectCasting(true); + + $result = $this->getJsonMapper([], $config) + ->mapWithReport( + [ + 'simple' => 'identifier', + ], + Base::class, + ); + + self::assertFalse($result->getReport()->hasErrors()); + $mapped = $result->getValue(); + self::assertInstanceOf(Base::class, $mapped); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 960db0d..cd26409 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,6 +14,7 @@ use Closure; use JsonException; use MagicSunday\JsonMapper; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -33,8 +34,9 @@ class TestCase extends \PHPUnit\Framework\TestCase * Returns an instance of the JsonMapper for testing. * * @param array $classMap + * @param JsonMapperConfig|null $config */ - protected function getJsonMapper(array $classMap = []): JsonMapper + protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config = null): JsonMapper { $listExtractors = [new ReflectionExtractor()]; $typeExtractors = [new PhpDocExtractor()]; @@ -45,6 +47,8 @@ protected function getJsonMapper(array $classMap = []): JsonMapper PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), $classMap, + null, + $config, ); } From b10f1b6893035e1e56c92f26a0afc2e3ba019242 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 07:39:07 +0100 Subject: [PATCH 09/46] Ensure JsonMapper defaults avoid nullable config --- src/JsonMapper.php | 21 +++++++------------ src/JsonMapper/Attribute/ReplaceProperty.php | 6 +++--- .../CollectionDocBlockTypeResolver.php | 12 +++-------- src/JsonMapper/Context/MappingContext.php | 20 +++++++++++------- src/JsonMapper/Context/MappingError.php | 8 +++---- src/JsonMapper/Report/MappingReport.php | 4 ++-- src/JsonMapper/Report/MappingResult.php | 6 +++--- src/JsonMapper/Type/TypeResolver.php | 12 ++++------- .../BuiltinValueConversionStrategy.php | 3 +-- .../DateTimeValueConversionStrategy.php | 3 ++- .../Strategy/EnumValueConversionStrategy.php | 3 ++- .../ObjectValueConversionStrategy.php | 14 ++++++------- tests/TestCase.php | 2 +- 13 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index f2b08aa..543f02a 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -105,8 +105,6 @@ class JsonMapper private CustomTypeRegistry $customTypeRegistry; - private JsonMapperConfig $config; - /** * @param array $classMap * @param CacheItemPoolInterface|null $typeCache @@ -119,9 +117,8 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - ?JsonMapperConfig $config = null, + private JsonMapperConfig $config = new JsonMapperConfig(), ) { - $this->config = $config ?? new JsonMapperConfig(); $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); $this->customTypeRegistry = new CustomTypeRegistry(); @@ -212,15 +209,13 @@ public function map( ?MappingContext $context = null, ?MappingConfiguration $configuration = null, ): mixed { - if ($context === null) { - $configuration = $configuration ?? $this->createDefaultConfiguration(); - $context = new MappingContext($json, $configuration->toOptions()); + if (!$context instanceof MappingContext) { + $configuration ??= $this->createDefaultConfiguration(); + $context = new MappingContext($json, $configuration->toOptions()); + } elseif (!$configuration instanceof MappingConfiguration) { + $configuration = MappingConfiguration::fromContext($context); } else { - if ($configuration === null) { - $configuration = MappingConfiguration::fromContext($context); - } else { - $context->replaceOptions($configuration->toOptions()); - } + $context->replaceOptions($configuration->toOptions()); } $resolvedClassName = $className === null @@ -418,7 +413,7 @@ private function createDefaultConfiguration(): MappingConfiguration $configuration = $configuration->withDefaultDateFormat($this->config->getDefaultDateFormat()); if ($this->config->shouldAllowScalarToObjectCasting()) { - $configuration = $configuration->withScalarToObjectCasting(true); + return $configuration->withScalarToObjectCasting(true); } return $configuration; diff --git a/src/JsonMapper/Attribute/ReplaceProperty.php b/src/JsonMapper/Attribute/ReplaceProperty.php index d4e5af3..cff49a6 100644 --- a/src/JsonMapper/Attribute/ReplaceProperty.php +++ b/src/JsonMapper/Attribute/ReplaceProperty.php @@ -17,11 +17,11 @@ * Attribute used to instruct the mapper to rename a JSON field. */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -final class ReplaceProperty +final readonly class ReplaceProperty { public function __construct( - public readonly string $value, - public readonly string $replaces, + public string $value, + public string $replaces, ) { } } diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php index 63a08e2..cb0cbd0 100644 --- a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -28,14 +28,10 @@ final class CollectionDocBlockTypeResolver { private DocBlockFactoryInterface $docBlockFactory; - private ContextFactory $contextFactory; - - private PhpDocTypeHelper $phpDocTypeHelper; - public function __construct( ?DocBlockFactoryInterface $docBlockFactory = null, - ?ContextFactory $contextFactory = null, - ?PhpDocTypeHelper $phpDocTypeHelper = null, + private ContextFactory $contextFactory = new ContextFactory(), + private PhpDocTypeHelper $phpDocTypeHelper = new PhpDocTypeHelper(), ) { if (!class_exists(DocBlockFactory::class)) { throw new LogicException( @@ -46,9 +42,7 @@ public function __construct( ); } - $this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); - $this->contextFactory = $contextFactory ?? new ContextFactory(); - $this->phpDocTypeHelper = $phpDocTypeHelper ?? new PhpDocTypeHelper(); + $this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); } /** diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index b9ecf8b..352fe78 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -24,13 +24,19 @@ */ final class MappingContext { - public const OPTION_STRICT_MODE = 'strict_mode'; - public const OPTION_COLLECT_ERRORS = 'collect_errors'; - public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; - public const OPTION_IGNORE_UNKNOWN_PROPERTIES = 'ignore_unknown_properties'; - public const OPTION_TREAT_NULL_AS_EMPTY_COLLECTION = 'treat_null_as_empty_collection'; - public const OPTION_DEFAULT_DATE_FORMAT = 'default_date_format'; - public const OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING = 'allow_scalar_to_object_casting'; + public const string OPTION_STRICT_MODE = 'strict_mode'; + + public const string OPTION_COLLECT_ERRORS = 'collect_errors'; + + public const string OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; + + public const string OPTION_IGNORE_UNKNOWN_PROPERTIES = 'ignore_unknown_properties'; + + public const string OPTION_TREAT_NULL_AS_EMPTY_COLLECTION = 'treat_null_as_empty_collection'; + + public const string OPTION_DEFAULT_DATE_FORMAT = 'default_date_format'; + + public const string OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING = 'allow_scalar_to_object_casting'; /** * @var list diff --git a/src/JsonMapper/Context/MappingError.php b/src/JsonMapper/Context/MappingError.php index 6bcc624..568bcbe 100644 --- a/src/JsonMapper/Context/MappingError.php +++ b/src/JsonMapper/Context/MappingError.php @@ -16,12 +16,12 @@ /** * Represents a collected mapping error. */ -final class MappingError +final readonly class MappingError { public function __construct( - private readonly string $path, - private readonly string $message, - private readonly ?MappingException $exception = null, + private string $path, + private string $message, + private ?MappingException $exception = null, ) { } diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php index 9195094..886a41e 100644 --- a/src/JsonMapper/Report/MappingReport.php +++ b/src/JsonMapper/Report/MappingReport.php @@ -16,12 +16,12 @@ /** * Represents the result of collecting mapping errors. */ -final class MappingReport +final readonly class MappingReport { /** * @param list $errors */ - public function __construct(private readonly array $errors) + public function __construct(private array $errors) { } diff --git a/src/JsonMapper/Report/MappingResult.php b/src/JsonMapper/Report/MappingResult.php index 0861e22..c6056d0 100644 --- a/src/JsonMapper/Report/MappingResult.php +++ b/src/JsonMapper/Report/MappingResult.php @@ -14,11 +14,11 @@ /** * Represents the outcome of a mapping operation and its report. */ -final class MappingResult +final readonly class MappingResult { public function __construct( - private readonly mixed $value, - private readonly MappingReport $report, + private mixed $value, + private MappingReport $report, ) { } diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index d004e8b..a6acbc8 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -28,7 +28,7 @@ */ final class TypeResolver { - private const CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; + private const string CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; private BuiltinType $defaultType; @@ -61,11 +61,7 @@ public function resolve(string $className, string $propertyName): Type $type = $this->resolveFromReflection($className, $propertyName); } - if ($type instanceof Type) { - $resolved = $this->normalizeType($type); - } else { - $resolved = $this->defaultType; - } + $resolved = $type instanceof Type ? $this->normalizeType($type) : $this->defaultType; $this->storeCachedType($className, $propertyName, $resolved); @@ -91,7 +87,7 @@ private function normalizeType(Type $type): Type */ private function getCachedType(string $className, string $propertyName): ?Type { - if ($this->cache === null) { + if (!$this->cache instanceof CacheItemPoolInterface) { return null; } @@ -119,7 +115,7 @@ private function getCachedType(string $className, string $propertyName): ?Type */ private function storeCachedType(string $className, string $propertyName, Type $type): void { - if ($this->cache === null) { + if (!$this->cache instanceof CacheItemPoolInterface) { return; } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index 74f65d7..4eacbbb 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -16,7 +16,6 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\TypeIdentifier; -use Traversable; use function assert; use function filter_var; @@ -169,7 +168,7 @@ private function isCompatibleValue(mixed $value, TypeIdentifier $identifier): bo 'array' => is_array($value), 'object' => is_object($value), 'callable' => is_callable($value), - 'iterable' => is_array($value) || $value instanceof Traversable, + 'iterable' => is_iterable($value), 'null' => $value === null, default => true, }; diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php index 077e318..ac60f44 100644 --- a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -18,6 +18,7 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; use function get_debug_type; use function is_a; @@ -35,7 +36,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo { $objectType = $this->extractObjectType($type); - if ($objectType === null) { + if (!$objectType instanceof ObjectType) { return false; } diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php index 0e7b553..1672d55 100644 --- a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -15,6 +15,7 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; use ValueError; use function enum_exists; @@ -34,7 +35,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo { $objectType = $this->extractObjectType($type); - if ($objectType === null) { + if (!$objectType instanceof ObjectType) { return false; } diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 5640f46..717ff35 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -51,14 +51,12 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe $className = $this->resolveClassName($type); $resolvedClass = $this->classResolver->resolve($className, $value, $context); - if (($value !== null) && !is_array($value) && !is_object($value)) { - if (!$context->shouldAllowScalarToObjectCasting()) { - $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); - $context->recordException($exception); - - if ($context->isStrictMode()) { - throw $exception; - } + if ($value !== null && !is_array($value) && !is_object($value) && !$context->shouldAllowScalarToObjectCasting()) { + $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index cd26409..19eac96 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -48,7 +48,7 @@ protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config new CamelCasePropertyNameConverter(), $classMap, null, - $config, + $config ?? new JsonMapperConfig(), ); } From 36ae4b04ed8f70c683e0dd11078270a6968175e4 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 07:54:15 +0100 Subject: [PATCH 10/46] refactor: unify mapper configuration --- src/JsonMapper.php | 54 ++--- .../Configuration/JsonMapperConfig.php | 167 -------------- ...ration.php => JsonMapperConfiguration.php} | 216 +++++++++++++----- .../Configuration/JsonMapperConfigTest.php | 79 ------- .../JsonMapperConfigurationTest.php | 135 +++++++++++ .../MappingConfigurationTest.php | 117 ---------- .../JsonMapperErrorHandlingTest.php | 12 +- tests/JsonMapperTest.php | 15 +- tests/TestCase.php | 8 +- 9 files changed, 333 insertions(+), 470 deletions(-) delete mode 100644 src/JsonMapper/Configuration/JsonMapperConfig.php rename src/JsonMapper/Configuration/{MappingConfiguration.php => JsonMapperConfiguration.php} (54%) delete mode 100644 tests/JsonMapper/Configuration/JsonMapperConfigTest.php create mode 100644 tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php delete mode 100644 tests/JsonMapper/Configuration/MappingConfigurationTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 543f02a..3f29a33 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -18,8 +18,7 @@ use MagicSunday\JsonMapper\Collection\CollectionDocBlockTypeResolver; use MagicSunday\JsonMapper\Collection\CollectionFactory; use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; -use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; -use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; use MagicSunday\JsonMapper\Exception\MappingException; @@ -117,7 +116,7 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - private JsonMapperConfig $config = new JsonMapperConfig(), + private JsonMapperConfiguration $config = new JsonMapperConfiguration(), ) { $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); @@ -145,7 +144,7 @@ function (string $className, ?array $arguments): object { new ObjectValueConversionStrategy( $this->classResolver, function (mixed $value, string $resolvedClass, MappingContext $context): mixed { - $configuration = MappingConfiguration::fromContext($context); + $configuration = JsonMapperConfiguration::fromContext($context); return $this->map($value, $resolvedClass, null, $context, $configuration); }, @@ -196,9 +195,11 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName + * @param MappingContext|null $context + * @param JsonMapperConfiguration|null $configuration * * @throws InvalidArgumentException */ @@ -207,13 +208,13 @@ public function map( ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, - ?MappingConfiguration $configuration = null, + ?JsonMapperConfiguration $configuration = null, ): mixed { if (!$context instanceof MappingContext) { $configuration ??= $this->createDefaultConfiguration(); $context = new MappingContext($json, $configuration->toOptions()); - } elseif (!$configuration instanceof MappingConfiguration) { - $configuration = MappingConfiguration::fromContext($context); + } elseif (!$configuration instanceof JsonMapperConfiguration) { + $configuration = JsonMapperConfiguration::fromContext($context); } else { $context->replaceOptions($configuration->toOptions()); } @@ -379,15 +380,16 @@ public function map( /** * Maps the JSON structure and returns a detailed mapping report. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName + * @param JsonMapperConfiguration|null $configuration */ public function mapWithReport( mixed $json, ?string $className = null, ?string $collectionClassName = null, - ?MappingConfiguration $configuration = null, + ?JsonMapperConfiguration $configuration = null, ): MappingResult { $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); $context = new MappingContext($json, $configuration->toOptions()); @@ -396,27 +398,9 @@ public function mapWithReport( return new MappingResult($value, new MappingReport($context->getErrorRecords())); } - private function createDefaultConfiguration(): MappingConfiguration + private function createDefaultConfiguration(): JsonMapperConfiguration { - $configuration = $this->config->isStrictMode() - ? MappingConfiguration::strict() - : MappingConfiguration::lenient(); - - if ($this->config->shouldIgnoreUnknownProperties()) { - $configuration = $configuration->withIgnoreUnknownProperties(true); - } - - if ($this->config->shouldTreatNullAsEmptyCollection()) { - $configuration = $configuration->withTreatNullAsEmptyCollection(true); - } - - $configuration = $configuration->withDefaultDateFormat($this->config->getDefaultDateFormat()); - - if ($this->config->shouldAllowScalarToObjectCasting()) { - return $configuration->withScalarToObjectCasting(true); - } - - return $configuration; + return clone $this->config; } /** @@ -470,7 +454,7 @@ private function isRequiredProperty(string $className, string $propertyName): bo return false; } - private function handleMappingException(MappingException $exception, MappingContext $context, MappingConfiguration $configuration): void + private function handleMappingException(MappingException $exception, MappingContext $context, JsonMapperConfiguration $configuration): void { $context->recordException($exception); diff --git a/src/JsonMapper/Configuration/JsonMapperConfig.php b/src/JsonMapper/Configuration/JsonMapperConfig.php deleted file mode 100644 index 807ae15..0000000 --- a/src/JsonMapper/Configuration/JsonMapperConfig.php +++ /dev/null @@ -1,167 +0,0 @@ - $data Configuration values indexed by property name - */ - public static function fromArray(array $data): self - { - $defaultDateFormat = $data['defaultDateFormat'] ?? DateTimeInterface::ATOM; - - if (!is_string($defaultDateFormat)) { - $defaultDateFormat = DateTimeInterface::ATOM; - } - - return new self( - (bool) ($data['strictMode'] ?? false), - (bool) ($data['ignoreUnknownProperties'] ?? false), - (bool) ($data['treatNullAsEmptyCollection'] ?? false), - $defaultDateFormat, - (bool) ($data['allowScalarToObjectCasting'] ?? false), - ); - } - - /** - * Serializes the configuration into an array representation. - * - * @return array - */ - public function toArray(): array - { - return [ - 'strictMode' => $this->strictMode, - 'ignoreUnknownProperties' => $this->ignoreUnknownProperties, - 'treatNullAsEmptyCollection' => $this->treatNullAsEmptyCollection, - 'defaultDateFormat' => $this->defaultDateFormat, - 'allowScalarToObjectCasting' => $this->allowScalarToObjectCasting, - ]; - } - - /** - * Indicates whether strict mode is enabled. - */ - public function isStrictMode(): bool - { - return $this->strictMode; - } - - /** - * Indicates whether unknown properties should be ignored during mapping. - */ - public function shouldIgnoreUnknownProperties(): bool - { - return $this->ignoreUnknownProperties; - } - - /** - * Indicates whether null collections should be treated as empty collections. - */ - public function shouldTreatNullAsEmptyCollection(): bool - { - return $this->treatNullAsEmptyCollection; - } - - /** - * Returns the default date format used by the mapper. - */ - public function getDefaultDateFormat(): string - { - return $this->defaultDateFormat; - } - - /** - * Indicates whether scalar values should be cast to objects when possible. - */ - public function shouldAllowScalarToObjectCasting(): bool - { - return $this->allowScalarToObjectCasting; - } - - /** - * Returns a copy with the strict mode flag toggled. - */ - public function withStrictMode(bool $enabled): self - { - $clone = clone $this; - $clone->strictMode = $enabled; - - return $clone; - } - - /** - * Returns a copy with the ignore-unknown-properties flag toggled. - */ - public function withIgnoreUnknownProperties(bool $enabled): self - { - $clone = clone $this; - $clone->ignoreUnknownProperties = $enabled; - - return $clone; - } - - /** - * Returns a copy with the treat-null-as-empty-collection flag toggled. - */ - public function withTreatNullAsEmptyCollection(bool $enabled): self - { - $clone = clone $this; - $clone->treatNullAsEmptyCollection = $enabled; - - return $clone; - } - - /** - * Returns a copy with a different default date format. - */ - public function withDefaultDateFormat(string $format): self - { - $clone = clone $this; - $clone->defaultDateFormat = $format; - - return $clone; - } - - /** - * Returns a copy with the scalar-to-object casting flag toggled. - */ - public function withScalarToObjectCasting(bool $enabled): self - { - $clone = clone $this; - $clone->allowScalarToObjectCasting = $enabled; - - return $clone; - } -} diff --git a/src/JsonMapper/Configuration/MappingConfiguration.php b/src/JsonMapper/Configuration/JsonMapperConfiguration.php similarity index 54% rename from src/JsonMapper/Configuration/MappingConfiguration.php rename to src/JsonMapper/Configuration/JsonMapperConfiguration.php index 7998cbe..e06578f 100644 --- a/src/JsonMapper/Configuration/MappingConfiguration.php +++ b/src/JsonMapper/Configuration/JsonMapperConfiguration.php @@ -14,11 +14,16 @@ use DateTimeInterface; use MagicSunday\JsonMapper\Context\MappingContext; +use function is_string; + /** - * Defines configuration options for mapping operations. + * Defines all configurable options available for JsonMapper. */ -final class MappingConfiguration +final class JsonMapperConfiguration { + /** + * Creates a new configuration instance with optional overrides. + */ public function __construct( private bool $strictMode = false, private bool $collectErrors = true, @@ -30,16 +35,49 @@ public function __construct( ) { } + /** + * Returns a lenient configuration with default settings. + */ public static function lenient(): self { return new self(); } + /** + * Returns a strict configuration that reports unknown and missing properties. + */ public static function strict(): self { - return new self(true, true); + return new self(true); } + /** + * Restores a configuration instance from the provided array. + * + * @param array $data Configuration values indexed by property name + */ + public static function fromArray(array $data): self + { + $defaultDateFormat = $data['defaultDateFormat'] ?? DateTimeInterface::ATOM; + + if (!is_string($defaultDateFormat) || $defaultDateFormat === '') { + $defaultDateFormat = DateTimeInterface::ATOM; + } + + return new self( + (bool) ($data['strictMode'] ?? false), + (bool) ($data['collectErrors'] ?? true), + (bool) ($data['emptyStringIsNull'] ?? false), + (bool) ($data['ignoreUnknownProperties'] ?? false), + (bool) ($data['treatNullAsEmptyCollection'] ?? false), + $defaultDateFormat, + (bool) ($data['allowScalarToObjectCasting'] ?? false), + ); + } + + /** + * Restores a configuration instance based on the provided mapping context. + */ public static function fromContext(MappingContext $context): self { return new self( @@ -53,6 +91,112 @@ public static function fromContext(MappingContext $context): self ); } + /** + * Serializes the configuration into an array representation. + * + * @return array + */ + public function toArray(): array + { + return [ + 'strictMode' => $this->strictMode, + 'collectErrors' => $this->collectErrors, + 'emptyStringIsNull' => $this->emptyStringIsNull, + 'ignoreUnknownProperties' => $this->ignoreUnknownProperties, + 'treatNullAsEmptyCollection' => $this->treatNullAsEmptyCollection, + 'defaultDateFormat' => $this->defaultDateFormat, + 'allowScalarToObjectCasting' => $this->allowScalarToObjectCasting, + ]; + } + + /** + * Converts the configuration to mapping context options. + * + * @return array + */ + public function toOptions(): array + { + return [ + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => $this->ignoreUnknownProperties, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => $this->treatNullAsEmptyCollection, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => $this->defaultDateFormat, + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => $this->allowScalarToObjectCasting, + ]; + } + + /** + * Indicates whether strict mode is enabled. + */ + public function isStrictMode(): bool + { + return $this->strictMode; + } + + /** + * Indicates whether errors should be collected during mapping. + */ + public function shouldCollectErrors(): bool + { + return $this->collectErrors; + } + + /** + * Indicates whether empty strings should be treated as null values. + */ + public function shouldTreatEmptyStringAsNull(): bool + { + return $this->emptyStringIsNull; + } + + /** + * Indicates whether unknown properties should be ignored. + */ + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + /** + * Indicates whether null collections should be converted to empty collections. + */ + public function shouldTreatNullAsEmptyCollection(): bool + { + return $this->treatNullAsEmptyCollection; + } + + /** + * Returns the default date format used for date conversions. + */ + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + /** + * Indicates whether scalar values may be cast to objects. + */ + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + + /** + * Returns a copy with the strict mode flag toggled. + */ + public function withStrictMode(bool $enabled): self + { + $clone = clone $this; + $clone->strictMode = $enabled; + + return $clone; + } + + /** + * Returns a copy with the error collection flag toggled. + */ public function withErrorCollection(bool $collect): self { $clone = clone $this; @@ -61,6 +205,9 @@ public function withErrorCollection(bool $collect): self return $clone; } + /** + * Returns a copy with the empty-string-as-null flag toggled. + */ public function withEmptyStringAsNull(bool $enabled): self { $clone = clone $this; @@ -69,6 +216,9 @@ public function withEmptyStringAsNull(bool $enabled): self return $clone; } + /** + * Returns a copy with the ignore-unknown-properties flag toggled. + */ public function withIgnoreUnknownProperties(bool $enabled): self { $clone = clone $this; @@ -77,6 +227,9 @@ public function withIgnoreUnknownProperties(bool $enabled): self return $clone; } + /** + * Returns a copy with the treat-null-as-empty-collection flag toggled. + */ public function withTreatNullAsEmptyCollection(bool $enabled): self { $clone = clone $this; @@ -85,6 +238,9 @@ public function withTreatNullAsEmptyCollection(bool $enabled): self return $clone; } + /** + * Returns a copy with a different default date format. + */ public function withDefaultDateFormat(string $format): self { $clone = clone $this; @@ -93,6 +249,9 @@ public function withDefaultDateFormat(string $format): self return $clone; } + /** + * Returns a copy with the scalar-to-object casting flag toggled. + */ public function withScalarToObjectCasting(bool $enabled): self { $clone = clone $this; @@ -100,55 +259,4 @@ public function withScalarToObjectCasting(bool $enabled): self return $clone; } - - public function isStrictMode(): bool - { - return $this->strictMode; - } - - public function shouldCollectErrors(): bool - { - return $this->collectErrors; - } - - public function shouldTreatEmptyStringAsNull(): bool - { - return $this->emptyStringIsNull; - } - - public function shouldIgnoreUnknownProperties(): bool - { - return $this->ignoreUnknownProperties; - } - - public function shouldTreatNullAsEmptyCollection(): bool - { - return $this->treatNullAsEmptyCollection; - } - - public function getDefaultDateFormat(): string - { - return $this->defaultDateFormat; - } - - public function shouldAllowScalarToObjectCasting(): bool - { - return $this->allowScalarToObjectCasting; - } - - /** - * @return array - */ - public function toOptions(): array - { - return [ - MappingContext::OPTION_STRICT_MODE => $this->strictMode, - MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, - MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => $this->ignoreUnknownProperties, - MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => $this->treatNullAsEmptyCollection, - MappingContext::OPTION_DEFAULT_DATE_FORMAT => $this->defaultDateFormat, - MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => $this->allowScalarToObjectCasting, - ]; - } } diff --git a/tests/JsonMapper/Configuration/JsonMapperConfigTest.php b/tests/JsonMapper/Configuration/JsonMapperConfigTest.php deleted file mode 100644 index 21bcdd9..0000000 --- a/tests/JsonMapper/Configuration/JsonMapperConfigTest.php +++ /dev/null @@ -1,79 +0,0 @@ -isStrictMode()); - self::assertFalse($config->shouldIgnoreUnknownProperties()); - self::assertFalse($config->shouldTreatNullAsEmptyCollection()); - self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); - self::assertFalse($config->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itSupportsImmutableUpdates(): void - { - $config = new JsonMapperConfig(); - - $modified = $config - ->withStrictMode(true) - ->withIgnoreUnknownProperties(true) - ->withTreatNullAsEmptyCollection(true) - ->withDefaultDateFormat('d.m.Y H:i:s') - ->withScalarToObjectCasting(true); - - self::assertFalse($config->isStrictMode()); - self::assertFalse($config->shouldIgnoreUnknownProperties()); - self::assertFalse($config->shouldTreatNullAsEmptyCollection()); - self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); - self::assertFalse($config->shouldAllowScalarToObjectCasting()); - - self::assertTrue($modified->isStrictMode()); - self::assertTrue($modified->shouldIgnoreUnknownProperties()); - self::assertTrue($modified->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y H:i:s', $modified->getDefaultDateFormat()); - self::assertTrue($modified->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itSerializesAndRestoresItself(): void - { - $config = (new JsonMapperConfig()) - ->withStrictMode(true) - ->withIgnoreUnknownProperties(true) - ->withTreatNullAsEmptyCollection(true) - ->withDefaultDateFormat('d.m.Y') - ->withScalarToObjectCasting(true); - - $restored = JsonMapperConfig::fromArray($config->toArray()); - - self::assertTrue($restored->isStrictMode()); - self::assertTrue($restored->shouldIgnoreUnknownProperties()); - self::assertTrue($restored->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y', $restored->getDefaultDateFormat()); - self::assertTrue($restored->shouldAllowScalarToObjectCasting()); - } -} diff --git a/tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php b/tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php new file mode 100644 index 0000000..89c6e96 --- /dev/null +++ b/tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php @@ -0,0 +1,135 @@ +isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); + self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itProvidesStrictPreset(): void + { + $configuration = JsonMapperConfiguration::strict(); + + self::assertTrue($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + } + + #[Test] + public function itSupportsImmutableUpdates(): void + { + $configuration = JsonMapperConfiguration::lenient(); + + $updated = $configuration + ->withStrictMode(true) + ->withErrorCollection(false) + ->withEmptyStringAsNull(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y H:i:s') + ->withScalarToObjectCasting(true); + + self::assertFalse($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); + self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); + + self::assertTrue($updated->isStrictMode()); + self::assertFalse($updated->shouldCollectErrors()); + self::assertTrue($updated->shouldTreatEmptyStringAsNull()); + self::assertTrue($updated->shouldIgnoreUnknownProperties()); + self::assertTrue($updated->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y H:i:s', $updated->getDefaultDateFormat()); + self::assertTrue($updated->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itSerializesAndRestoresFromArrays(): void + { + $configuration = JsonMapperConfiguration::lenient() + ->withStrictMode(true) + ->withErrorCollection(false) + ->withEmptyStringAsNull(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y') + ->withScalarToObjectCasting(true); + + $restored = JsonMapperConfiguration::fromArray($configuration->toArray()); + + self::assertTrue($restored->isStrictMode()); + self::assertFalse($restored->shouldCollectErrors()); + self::assertTrue($restored->shouldTreatEmptyStringAsNull()); + self::assertTrue($restored->shouldIgnoreUnknownProperties()); + self::assertTrue($restored->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $restored->getDefaultDateFormat()); + self::assertTrue($restored->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itRestoresFromContext(): void + { + $context = new MappingContext([], [ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => false, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => true, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, + ]); + + $configuration = JsonMapperConfiguration::fromContext($context); + + self::assertTrue($configuration->isStrictMode()); + self::assertFalse($configuration->shouldCollectErrors()); + self::assertTrue($configuration->shouldTreatEmptyStringAsNull()); + self::assertTrue($configuration->shouldIgnoreUnknownProperties()); + self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $configuration->getDefaultDateFormat()); + self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); + self::assertSame([ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => false, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => true, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, + ], $configuration->toOptions()); + } +} diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php deleted file mode 100644 index aab92cb..0000000 --- a/tests/JsonMapper/Configuration/MappingConfigurationTest.php +++ /dev/null @@ -1,117 +0,0 @@ -isStrictMode()); - self::assertTrue($configuration->shouldCollectErrors()); - self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); - self::assertFalse($configuration->shouldIgnoreUnknownProperties()); - self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); - self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); - self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itEnablesStrictMode(): void - { - $configuration = MappingConfiguration::strict(); - - self::assertTrue($configuration->isStrictMode()); - self::assertTrue($configuration->shouldCollectErrors()); - self::assertFalse($configuration->shouldIgnoreUnknownProperties()); - } - - #[Test] - public function itSupportsTogglingErrorCollection(): void - { - $configuration = MappingConfiguration::lenient()->withErrorCollection(false); - - self::assertFalse($configuration->isStrictMode()); - self::assertFalse($configuration->shouldCollectErrors()); - } - - #[Test] - public function itSupportsEmptyStringConfiguration(): void - { - $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); - - self::assertTrue($configuration->shouldTreatEmptyStringAsNull()); - self::assertTrue($configuration->withEmptyStringAsNull(true)->shouldTreatEmptyStringAsNull()); - } - - #[Test] - public function itSupportsExtendedFlags(): void - { - $configuration = MappingConfiguration::lenient() - ->withIgnoreUnknownProperties(true) - ->withTreatNullAsEmptyCollection(true) - ->withDefaultDateFormat('d.m.Y H:i:s') - ->withScalarToObjectCasting(true); - - self::assertTrue($configuration->shouldIgnoreUnknownProperties()); - self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y H:i:s', $configuration->getDefaultDateFormat()); - self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itDerivesFromContext(): void - { - $context = new MappingContext([], [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, - MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, - MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, - MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', - MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, - ]); - - $configuration = MappingConfiguration::fromContext($context); - - self::assertTrue($configuration->isStrictMode()); - self::assertTrue($configuration->shouldCollectErrors()); - self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); - self::assertTrue($configuration->shouldIgnoreUnknownProperties()); - self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y', $configuration->getDefaultDateFormat()); - self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); - self::assertSame( - [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, - MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, - MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, - MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', - MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, - ], - $configuration->toOptions(), - ); - } -} diff --git a/tests/JsonMapper/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php index e53132c..e05c28d 100644 --- a/tests/JsonMapper/JsonMapperErrorHandlingTest.php +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -11,7 +11,7 @@ namespace MagicSunday\Test\JsonMapper; -use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Exception\CollectionMappingException; use MagicSunday\JsonMapper\Exception\MissingPropertyException; use MagicSunday\JsonMapper\Exception\ReadonlyPropertyException; @@ -63,7 +63,7 @@ public function itThrowsOnUnknownPropertiesInStrictMode(): void Person::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -78,7 +78,7 @@ public function itThrowsOnMissingRequiredProperties(): void Person::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -93,7 +93,7 @@ public function itThrowsOnTypeMismatch(): void Base::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -111,7 +111,7 @@ public function itThrowsOnInvalidCollectionPayloads(): void Base::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -213,7 +213,7 @@ public function itThrowsOnInvalidNestedCollectionEntriesInStrictMode(): void Base::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } } diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index 4ec3a31..ee78f2b 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -12,8 +12,7 @@ namespace MagicSunday\Test; use DateInterval; -use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; -use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\Test\Classes\Base; @@ -883,7 +882,7 @@ public function mapScalarZeroStringToFalse(): void #[Test] public function mapEmptyStringToNullWhenEnabled(): void { - $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); + $configuration = JsonMapperConfiguration::lenient()->withEmptyStringAsNull(true); $result = $this->getJsonMapper() ->map( @@ -901,7 +900,7 @@ public function mapEmptyStringToNullWhenEnabled(): void #[Test] public function itAppliesConfiguredStrictModeByDefault(): void { - $config = (new JsonMapperConfig())->withStrictMode(true); + $config = (new JsonMapperConfiguration())->withStrictMode(true); $this->expectException(UnknownPropertyException::class); @@ -917,7 +916,7 @@ public function itAppliesConfiguredStrictModeByDefault(): void #[Test] public function itIgnoresUnknownPropertiesWhenConfigured(): void { - $config = (new JsonMapperConfig())->withIgnoreUnknownProperties(true); + $config = (new JsonMapperConfiguration())->withIgnoreUnknownProperties(true); $result = $this->getJsonMapper([], $config) ->mapWithReport( @@ -935,7 +934,7 @@ public function itIgnoresUnknownPropertiesWhenConfigured(): void #[Test] public function itTreatsNullCollectionsAsEmptyWhenConfigured(): void { - $config = (new JsonMapperConfig())->withTreatNullAsEmptyCollection(true); + $config = (new JsonMapperConfiguration())->withTreatNullAsEmptyCollection(true); $result = $this->getJsonMapper([], $config) ->map( @@ -952,7 +951,7 @@ public function itTreatsNullCollectionsAsEmptyWhenConfigured(): void #[Test] public function itUsesDefaultDateFormatFromConfiguration(): void { - $config = (new JsonMapperConfig())->withDefaultDateFormat('d.m.Y H:i:s'); + $config = (new JsonMapperConfiguration())->withDefaultDateFormat('d.m.Y H:i:s'); $result = $this->getJsonMapper([], $config) ->map( @@ -969,7 +968,7 @@ public function itUsesDefaultDateFormatFromConfiguration(): void #[Test] public function itAllowsScalarToObjectCastingWhenConfigured(): void { - $config = (new JsonMapperConfig())->withScalarToObjectCasting(true); + $config = (new JsonMapperConfiguration())->withScalarToObjectCasting(true); $result = $this->getJsonMapper([], $config) ->mapWithReport( diff --git a/tests/TestCase.php b/tests/TestCase.php index 19eac96..47415b1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,7 +14,7 @@ use Closure; use JsonException; use MagicSunday\JsonMapper; -use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -34,9 +34,9 @@ class TestCase extends \PHPUnit\Framework\TestCase * Returns an instance of the JsonMapper for testing. * * @param array $classMap - * @param JsonMapperConfig|null $config + * @param JsonMapperConfiguration|null $config */ - protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config = null): JsonMapper + protected function getJsonMapper(array $classMap = [], ?JsonMapperConfiguration $config = null): JsonMapper { $listExtractors = [new ReflectionExtractor()]; $typeExtractors = [new PhpDocExtractor()]; @@ -48,7 +48,7 @@ protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config new CamelCasePropertyNameConverter(), $classMap, null, - $config ?? new JsonMapperConfig(), + $config ?? new JsonMapperConfiguration(), ); } From f0a3ce11b9b4af2fad111b9dfd16bfacde0c7313 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 20:03:07 +0100 Subject: [PATCH 11/46] Refactor mapper responsibilities into dedicated services --- src/JsonMapper.php | 562 +++++------------- .../Collection/CollectionFactory.php | 85 +++ src/JsonMapper/Context/MappingContext.php | 111 ++++ src/JsonMapper/Resolver/ClassResolver.php | 88 +++ src/JsonMapper/Type/TypeResolver.php | 47 ++ src/JsonMapper/Value/CustomTypeRegistry.php | 79 +++ .../BuiltinValueConversionStrategy.php | 37 ++ .../CollectionValueConversionStrategy.php | 43 ++ .../CustomTypeValueConversionStrategy.php | 40 ++ .../Strategy/NullValueConversionStrategy.php | 31 + .../ObjectValueConversionStrategy.php | 49 ++ .../PassthroughValueConversionStrategy.php | 31 + .../ValueConversionStrategyInterface.php | 25 + src/JsonMapper/Value/ValueConverter.php | 50 ++ .../JsonMapper/Context/MappingContextTest.php | 62 ++ .../JsonMapper/Resolver/ClassResolverTest.php | 57 ++ .../Value/CustomTypeRegistryTest.php | 51 ++ 17 files changed, 1036 insertions(+), 412 deletions(-) create mode 100644 src/JsonMapper/Collection/CollectionFactory.php create mode 100644 src/JsonMapper/Context/MappingContext.php create mode 100644 src/JsonMapper/Resolver/ClassResolver.php create mode 100644 src/JsonMapper/Type/TypeResolver.php create mode 100644 src/JsonMapper/Value/CustomTypeRegistry.php create mode 100644 src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php create mode 100644 src/JsonMapper/Value/ValueConverter.php create mode 100644 tests/JsonMapper/Context/MappingContextTest.php create mode 100644 tests/JsonMapper/Resolver/ClassResolverTest.php create mode 100644 tests/JsonMapper/Value/CustomTypeRegistryTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 600ee95..c39781b 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -14,11 +14,22 @@ use Closure; use Doctrine\Common\Annotations\Annotation; use Doctrine\Common\Annotations\AnnotationReader; -use DomainException; use InvalidArgumentException; use MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue; use MagicSunday\JsonMapper\Annotation\ReplaceProperty; +use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; +use MagicSunday\JsonMapper\Resolver\ClassResolver; +use MagicSunday\JsonMapper\Type\TypeResolver; +use MagicSunday\JsonMapper\Value\CustomTypeRegistry; +use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\CollectionValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\CustomTypeValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\NullValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; +use MagicSunday\JsonMapper\Value\ValueConverter; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -26,18 +37,21 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; -use Symfony\Component\TypeInfo\TypeIdentifier; use function array_key_exists; +use function call_user_func_array; +use function count; use function in_array; use function is_array; +use function is_callable; use function is_int; use function is_object; +use function is_string; +use function method_exists; +use function sprintf; +use function ucfirst; /** * JsonMapper. @@ -51,77 +65,59 @@ */ class JsonMapper { - /** - * @var PropertyInfoExtractorInterface - */ - private PropertyInfoExtractorInterface $extractor; + private TypeResolver $typeResolver; - /** - * @var PropertyAccessorInterface - */ - private PropertyAccessorInterface $accessor; + private ClassResolver $classResolver; - /** - * The property name converter instance. - * - * @var PropertyNameConverterInterface|null - */ - protected ?PropertyNameConverterInterface $nameConverter; + private ValueConverter $valueConverter; - /** - * Override class names that JsonMapper uses to create objects. Useful when your - * setter methods accept abstract classes or interfaces. - * - * @var string[]|Closure[] - */ - private array $classMap; + private CollectionFactory $collectionFactory; - /** - * The default value type instance. - * - * @var BuiltinType - */ - private BuiltinType $defaultType; + private CustomTypeRegistry $customTypeRegistry; - /** - * The custom types. - * - * @var Closure[] - */ - private array $types = []; - - /** - * JsonMapper constructor. - * - * @param PropertyInfoExtractorInterface $extractor - * @param PropertyAccessorInterface $accessor - * @param PropertyNameConverterInterface|null $nameConverter A name converter instance - * @param string[]|Closure[] $classMap A class map to override the class names - */ public function __construct( - PropertyInfoExtractorInterface $extractor, - PropertyAccessorInterface $accessor, - ?PropertyNameConverterInterface $nameConverter = null, + private readonly PropertyInfoExtractorInterface $extractor, + private readonly PropertyAccessorInterface $accessor, + private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ) { - $this->extractor = $extractor; - $this->accessor = $accessor; - $this->nameConverter = $nameConverter; - $this->defaultType = new BuiltinType(TypeIdentifier::STRING); - $this->classMap = $classMap; + $this->typeResolver = new TypeResolver($extractor); + $this->classResolver = new ClassResolver($classMap); + $this->customTypeRegistry = new CustomTypeRegistry(); + $this->valueConverter = new ValueConverter(); + $this->collectionFactory = new CollectionFactory( + $this->valueConverter, + $this->classResolver, + function (string $className, ?array $arguments): object { + if ($arguments === null) { + return $this->makeInstance($className, null); + } + + return $this->makeInstance($className, $arguments); + }, + ); + + $this->valueConverter->addStrategy(new NullValueConversionStrategy()); + $this->valueConverter->addStrategy(new CollectionValueConversionStrategy($this->collectionFactory)); + $this->valueConverter->addStrategy(new CustomTypeValueConversionStrategy($this->customTypeRegistry)); + $this->valueConverter->addStrategy( + new ObjectValueConversionStrategy( + $this->classResolver, + function (mixed $value, string $resolvedClass, MappingContext $context): mixed { + return $this->map($value, $resolvedClass, null, $context); + }, + ), + ); + $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); + $this->valueConverter->addStrategy(new PassthroughValueConversionStrategy()); } /** * Add a custom type. - * - * @param string $type The type name - * @param Closure $closure The closure to execute for the defined type - * - * @return JsonMapper */ public function addType(string $type, Closure $closure): JsonMapper { - $this->types[$type] = $closure; + $this->customTypeRegistry->register($type, $closure); return $this; } @@ -131,14 +127,11 @@ public function addType(string $type, Closure $closure): JsonMapper * * @template T * - * @param class-string $className The name of the base class - * @param Closure $closure The closure to execute if the base class was found - * - * @return JsonMapper + * @param class-string $className */ public function addCustomClassMapEntry(string $className, Closure $closure): JsonMapper { - $this->classMap[$className] = $closure; + $this->classResolver->add($className, $closure); return $this; } @@ -146,10 +139,9 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json The JSON to map - * @param class-string|null $className The class name of the initial element - * @param class-string|null $collectionClassName The class name of a collection used to assign - * the initial elements + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName * * @return mixed|TEntityCollection|TEntity|null * @@ -157,117 +149,106 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso * ? TEntityCollection * : ($className is class-string ? TEntity : null|mixed)) * - * @throws DomainException * @throws InvalidArgumentException */ - public function map(mixed $json, ?string $className = null, ?string $collectionClassName = null) - { - // Return plain JSON if no mapping classes are provided + public function map( + mixed $json, + ?string $className = null, + ?string $collectionClassName = null, + ?MappingContext $context = null, + ) { + $context ??= new MappingContext($json); + if ($className === null) { return $json; } - // Map the original given class names to a custom ones - $className = $this->getMappedClassName($className, $json); + $className = $this->classResolver->resolve($className, $json, $context); if ($collectionClassName !== null) { - $collectionClassName = $this->getMappedClassName($collectionClassName, $json); + $collectionClassName = $this->classResolver->resolve($collectionClassName, $json, $context); } - // Assert that the given classes exist $this->assertClassesExists($className, $collectionClassName); - // Handle collections if ($this->isIterableWithArraysOrObjects($json)) { - /** @var array|object $json */ if ($collectionClassName !== null) { - // Map arrays into collection class if given - return $this->makeInstance( - $collectionClassName, - $this->asCollection( - $json, - new ObjectType($className) - ) - ); + $collection = $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); + + return $this->makeInstance($collectionClassName, $collection); } - // Handle plain array collections if ($this->isNumericIndexArray($json)) { - // Map all elements of the JSON array to an array - return $this->asCollection( - $json, - new ObjectType($className) - ); + return $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); } } - $properties = $this->getProperties($className); - $entity = $this->makeInstance($className); + $entity = $this->makeInstance($className); - // Return entity if JSON is not an array or object (is_iterable won't work here) if (!is_array($json) && !is_object($json)) { return $entity; } - // Process all children + $properties = $this->getProperties($className); + $replacePropertyMap = $this->buildReplacePropertyMap($className); - /** @var string|int $propertyName */ foreach ($json as $propertyName => $propertyValue) { - // Replaces the property name with another one - if ($this->isReplacePropertyAnnotation($className)) { - $annotations = $this->extractClassAnnotations($className); - - foreach ($annotations as $annotation) { - if ( - ($annotation instanceof ReplaceProperty) - && ($propertyName === $annotation->replaces) - ) { - /** @var string $propertyName */ - $propertyName = $annotation->value; - } - } - } + $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); - if (is_string($propertyName) - && ($this->nameConverter instanceof PropertyNameConverterInterface) - ) { - $propertyName = $this->nameConverter->convert($propertyName); + if (!is_string($normalizedProperty)) { + continue; } - // Ignore all not defined properties - if (!in_array($propertyName, $properties, true)) { + if (!in_array($normalizedProperty, $properties, true)) { continue; } - $type = $this->getType($className, $propertyName); - $value = $this->getValue($propertyValue, $type); - - if ( - ($value === null) - && $this->isReplaceNullWithDefaultValueAnnotation($className, $propertyName) - ) { - // Get the default value of the property - $value = $this->getDefaultValue($className, $propertyName); - } + $context->withPathSegment($normalizedProperty, function (MappingContext $propertyContext) use ( + $className, + $normalizedProperty, + $propertyValue, + $entity, + ): void { + $type = $this->typeResolver->resolve($className, $normalizedProperty, $propertyContext); + $value = $this->convertValue($propertyValue, $type, $propertyContext); + + if ( + ($value === null) + && $this->isReplaceNullWithDefaultValueAnnotation($className, $normalizedProperty) + ) { + $value = $this->getDefaultValue($className, $normalizedProperty); + } - $this->setProperty($entity, $propertyName, $value); + $this->setProperty($entity, $normalizedProperty, $value); + }); } return $entity; } /** - * Creates an instance of the given class name. If a dependency injection container is provided, - * it returns the instance for this. + * Converts the provided JSON value using the registered strategies. + */ + private function convertValue(mixed $json, Type $type, MappingContext $context): mixed + { + if ($type instanceof CollectionType) { + return $this->collectionFactory->fromCollectionType($type, $json, $context); + } + + return $this->valueConverter->convert($json, $type, $context); + } + + /** + * Creates an instance of the given class name. * * @template T of object * - * @param class-string $className The class to instantiate - * @param array|null ...$constructorArguments The arguments of the constructor + * @param class-string $className + * @param mixed ...$constructorArguments * * @return T */ - private function makeInstance(string $className, ?array ...$constructorArguments) + private function makeInstance(string $className, mixed ...$constructorArguments) { /** @var T $instance */ $instance = new $className(...$constructorArguments); @@ -277,43 +258,56 @@ private function makeInstance(string $className, ?array ...$constructorArguments /** * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * - * @return bool */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { return $this->hasPropertyAnnotation( $className, $propertyName, - ReplaceNullWithDefaultValue::class + ReplaceNullWithDefaultValue::class, ); } /** - * Returns TRUE if the property contains an "ReplaceProperty" annotation. - * - * @param class-string $className The class name of the initial element + * Builds the map of properties replaced by the annotation. * - * @return bool + * @return array */ - private function isReplacePropertyAnnotation(string $className): bool + private function buildReplacePropertyMap(string $className): array { - return $this->hasClassAnnotation( - $className, - ReplaceProperty::class - ); + $map = []; + + foreach ($this->extractClassAnnotations($className) as $annotation) { + if (!($annotation instanceof ReplaceProperty)) { + continue; + } + + $map[$annotation->replaces] = $annotation->value; + } + + return $map; + } + + /** + * Normalizes the property name using annotations and converters. + */ + private function normalizePropertyName(string|int $propertyName, array $replacePropertyMap): string|int + { + $normalized = $propertyName; + + if (is_string($normalized) && array_key_exists($normalized, $replacePropertyMap)) { + $normalized = $replacePropertyMap[$normalized]; + } + + if (is_string($normalized) && ($this->nameConverter instanceof PropertyNameConverterInterface)) { + $normalized = $this->nameConverter->convert($normalized); + } + + return $normalized; } /** * Returns the specified reflection property. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * - * @return ReflectionProperty|null */ private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty { @@ -326,10 +320,6 @@ private function getReflectionProperty(string $className, string $propertyName): /** * Returns the specified reflection class. - * - * @param class-string $className The class name of the initial element - * - * @return ReflectionClass|null */ private function getReflectionClass(string $className): ?ReflectionClass { @@ -343,9 +333,6 @@ private function getReflectionClass(string $className): ?ReflectionClass /** * Extracts possible property annotations. * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * * @return Annotation[]|object[] */ private function extractPropertyAnnotations(string $className, string $propertyName): array @@ -363,8 +350,6 @@ private function extractPropertyAnnotations(string $className, string $propertyN /** * Extracts possible class annotations. * - * @param class-string $className The class name of the initial element - * * @return Annotation[]|object[] */ private function extractClassAnnotations(string $className): array @@ -381,12 +366,6 @@ private function extractClassAnnotations(string $className): array /** * Returns TRUE if the property has the given annotation. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * @param string $annotationName The name of the property annotation - * - * @return bool */ private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool { @@ -402,33 +381,7 @@ private function hasPropertyAnnotation(string $className, string $propertyName, } /** - * Returns TRUE if the class has the given annotation. - * - * @param class-string $className The class name of the initial element - * @param string $annotationName The name of the class annotation - * - * @return bool - */ - private function hasClassAnnotation(string $className, string $annotationName): bool - { - $annotations = $this->extractClassAnnotations($className); - - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - return true; - } - } - - return false; - } - - /** - * Extracts the default value of a property. - * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property - * - * @return mixed|null + * Returns the default value of a property. */ private function getDefaultValue(string $className, string $propertyName): mixed { @@ -443,10 +396,6 @@ private function getDefaultValue(string $className, string $propertyName): mixed /** * Returns TRUE if the given JSON contains integer property keys. - * - * @param array|object $json - * - * @return bool */ private function isNumericIndexArray(array|object $json): bool { @@ -461,14 +410,9 @@ private function isNumericIndexArray(array|object $json): bool /** * Returns TRUE if the given JSON is a plain array or object. - * - * @param mixed $json - * - * @return bool */ private function isIterableWithArraysOrObjects(mixed $json): bool { - // Return false if JSON is not an array or object (is_iterable won't work here) if (!is_array($json) && !is_object($json)) { return false; } @@ -490,12 +434,6 @@ private function isIterableWithArraysOrObjects(mixed $json): bool /** * Assert that the given classes exist. - * - * @param class-string $className The class name of the initial element - * @param class-string|null $collectionClassName The class name of a collection used to - * assign the initial elements - * - * @throws InvalidArgumentException */ private function assertClassesExists(string $className, ?string $collectionClassName = null): void { @@ -516,14 +454,9 @@ private function assertClassesExists(string $className, ?string $collectionClass /** * Sets a property value. - * - * @param object $entity - * @param string $name - * @param mixed $value */ private function setProperty(object $entity, string $name, mixed $value): void { - // Handle variadic setters if (is_array($value)) { $methodName = 'set' . ucfirst($name); @@ -549,205 +482,10 @@ private function setProperty(object $entity, string $name, mixed $value): void /** * Get all public properties for the specified class. * - * @param string $className The name of the class used to extract the properties - * * @return string[] */ private function getProperties(string $className): array { return $this->extractor->getProperties($className) ?? []; } - - /** - * Determine the type for the specified property using reflection. - * - * @param string $className The name of the class used to extract the property type info - * @param string $propertyName The name of the property - * - * @return Type - */ - private function getType(string $className, string $propertyName): Type - { - $extractedType = $this->extractor->getType($className, $propertyName) ?? $this->defaultType; - - if ($extractedType instanceof UnionType) { - return $extractedType->getTypes()[0]; - } - - return $extractedType; - } - - /** - * Get the value for the specified node. - * - * @param mixed $json - * @param Type $type - * - * @return mixed|null - * - * @throws DomainException - */ - private function getValue(mixed $json, Type $type): mixed - { - if ( - (is_array($json) || is_object($json)) - && ($type instanceof CollectionType) - ) { - $collectionType = $type->getCollectionValueType(); - $collection = $this->asCollection($json, $collectionType); - $wrappedType = $type->getWrappedType(); - - // Create a new instance of the collection class - if ( - ($wrappedType instanceof WrappingTypeInterface) - && ($wrappedType->getWrappedType() instanceof ObjectType) - ) { - return $this->makeInstance( - $this->getClassName($json, $wrappedType->getWrappedType()), - $collection - ); - } - - return $collection; - } - - // Ignore empty values - if ($json === null) { - return null; - } - - if ($type instanceof ObjectType) { - return $this->asObject($json, $type); - } - - if ($type instanceof BuiltinType) { - settype($json, $type->getTypeIdentifier()->value); - } - - return $json; - } - - /** - * Returns the mapped class name. - * - * @param class-string|string $className The class name to be mapped using the class map - * @param mixed $json The JSON data - * - * @return class-string - * - * @throws DomainException - */ - private function getMappedClassName(string $className, mixed $json): string - { - if (array_key_exists($className, $this->classMap)) { - $classNameOrClosure = $this->classMap[$className]; - - if (!($classNameOrClosure instanceof Closure)) { - /** @var class-string $classNameOrClosure */ - return $classNameOrClosure; - } - - // Execute closure to get the mapped class name - $className = $classNameOrClosure($json); - } - - /** @var class-string $className */ - return $className; - } - - /** - * Returns the class name. - * - * @param mixed $json - * @param ObjectType $type - * - * @return class-string - * - * @throws DomainException - */ - private function getClassName(mixed $json, ObjectType $type): string - { - return $this->getMappedClassName( - $type->getClassName(), - $json - ); - } - - /** - * Cast node to a collection. - * - * @param array|object|null $json - * @param Type $type - * - * @return mixed[]|null - * - * @throws DomainException - */ - private function asCollection(array|object|null $json, Type $type): ?array - { - if ($json === null) { - return null; - } - - $collection = []; - - foreach ($json as $key => $value) { - $collection[$key] = $this->getValue($value, $type); - } - - return $collection; - } - - /** - * Cast node to object. - * - * @param mixed $json - * @param ObjectType $type - * - * @return mixed|null - * - * @throws DomainException - */ - private function asObject(mixed $json, ObjectType $type): mixed - { - /** @var class-string $className */ - $className = $this->getClassName($json, $type); - - if ($this->isCustomType($className)) { - return $this->callCustomClosure($json, $className); - } - - return $this->map($json, $className); - } - - /** - * Determine if the specified type is a custom type. - * - * @template T - * - * @param class-string $typeClassName - * - * @return bool - */ - private function isCustomType(string $typeClassName): bool - { - return array_key_exists($typeClassName, $this->types); - } - - /** - * Call the custom closure for the specified type. - * - * @template T - * - * @param mixed $json - * @param class-string $typeClassName - * - * @return mixed - */ - private function callCustomClosure(mixed $json, string $typeClassName): mixed - { - $callback = $this->types[$typeClassName]; - - return $callback($json); - } } diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php new file mode 100644 index 0000000..e1a7a95 --- /dev/null +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -0,0 +1,85 @@ +|null):object $instantiator + */ + public function __construct( + private readonly ValueConverter $valueConverter, + private readonly ClassResolver $classResolver, + private readonly Closure $instantiator, + ) { + } + + /** + * Converts the provided iterable JSON structure to a PHP array. + */ + public function mapIterable(array|object|null $json, Type $valueType, MappingContext $context): ?array + { + if ($json === null) { + return null; + } + + if (!is_array($json) && !is_object($json)) { + return null; + } + + $collection = []; + + foreach ($json as $key => $value) { + $collection[$key] = $context->withPathSegment((string) $key, function (MappingContext $childContext) use ($valueType, $value): mixed { + return $this->valueConverter->convert($value, $valueType, $childContext); + }); + } + + return $collection; + } + + /** + * Builds a collection based on the specified collection type description. + */ + public function fromCollectionType(CollectionType $type, array|object|null $json, MappingContext $context): mixed + { + $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); + + $wrappedType = $type->getWrappedType(); + + if (($wrappedType instanceof WrappingTypeInterface) && ($wrappedType->getWrappedType() instanceof ObjectType)) { + $objectType = $wrappedType->getWrappedType(); + $className = $this->classResolver->resolve($objectType->getClassName(), $json, $context); + + $instantiator = $this->instantiator; + + return $instantiator($className, $collection); + } + + return $collection; + } +} diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php new file mode 100644 index 0000000..6927c4c --- /dev/null +++ b/src/JsonMapper/Context/MappingContext.php @@ -0,0 +1,111 @@ + + */ + private array $pathSegments; + + /** + * @var list + */ + private array $errors = []; + + /** + * @param mixed $rootInput The original JSON payload + * @param array $options Context options + */ + public function __construct( + private readonly mixed $rootInput, + private readonly array $options = [], + ) { + $this->pathSegments = []; + } + + /** + * Returns the root JSON input value. + */ + public function getRootInput(): mixed + { + return $this->rootInput; + } + + /** + * Returns the current path inside the JSON structure. + */ + public function getPath(): string + { + if ($this->pathSegments === []) { + return '$'; + } + + return '$.' . implode('.', $this->pathSegments); + } + + /** + * Executes the callback while appending the provided segment to the path. + * + * @param callable(self):mixed $callback + */ + public function withPathSegment(string|int $segment, callable $callback): mixed + { + $this->pathSegments[] = (string) $segment; + + try { + return $callback($this); + } finally { + array_pop($this->pathSegments); + } + } + + /** + * Stores the error message for later consumption. + */ + public function addError(string $message): void + { + $this->errors[] = $message; + } + + /** + * Returns collected mapping errors. + * + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Returns all options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Returns a single option by name. + */ + public function getOption(string $name, mixed $default = null): mixed + { + return $this->options[$name] ?? $default; + } +} diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php new file mode 100644 index 0000000..e38377b --- /dev/null +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -0,0 +1,88 @@ + $classMap + */ + public function __construct( + private array $classMap = [], + ) { + } + + /** + * Adds a custom resolution rule. + */ + public function add(string $className, Closure $resolver): void + { + $this->classMap[$className] = $resolver; + } + + /** + * Resolves the class name for the provided JSON payload. + * + * @param class-string $className + * + * @return class-string + */ + public function resolve(string $className, mixed $json, MappingContext $context): string + { + if (!array_key_exists($className, $this->classMap)) { + return $className; + } + + $mapped = $this->classMap[$className]; + + if (!($mapped instanceof Closure)) { + return $mapped; + } + + $resolved = $this->invokeResolver($mapped, $json, $context); + + if (!is_string($resolved)) { + throw new DomainException( + sprintf( + 'Class resolver for %s must return a class-string, %s given.', + $className, + get_debug_type($resolved), + ), + ); + } + + return $resolved; + } + + private function invokeResolver(Closure $resolver, mixed $json, MappingContext $context): mixed + { + $reflection = new ReflectionFunction($resolver); + + if ($reflection->getNumberOfParameters() >= 2) { + return $resolver($json, $context); + } + + return $resolver($json); + } +} diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php new file mode 100644 index 0000000..7c1b91a --- /dev/null +++ b/src/JsonMapper/Type/TypeResolver.php @@ -0,0 +1,47 @@ +defaultType = new BuiltinType(TypeIdentifier::STRING); + } + + /** + * Resolves the declared type for the provided property. + */ + public function resolve(string $className, string $propertyName, MappingContext $context): Type + { + $type = $this->extractor->getType($className, $propertyName); + + if ($type instanceof UnionType) { + return $type->getTypes()[0]; + } + + return $type ?? $this->defaultType; + } +} diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php new file mode 100644 index 0000000..0539c7b --- /dev/null +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -0,0 +1,79 @@ + + */ + private array $converters = []; + + /** + * Registers the converter for the provided class name. + */ + public function register(string $className, callable $converter): void + { + $this->converters[$className] = $this->normalizeConverter($converter); + } + + /** + * Returns TRUE if a converter for the class exists. + */ + public function has(string $className): bool + { + return array_key_exists($className, $this->converters); + } + + /** + * Executes the converter for the class. + */ + public function convert(string $className, mixed $value, MappingContext $context): mixed + { + return $this->converters[$className]($value, $context); + } + + /** + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + * + * @return callable(mixed, MappingContext):mixed + */ + private function normalizeConverter(callable $converter): callable + { + if ($converter instanceof Closure) { + $reflection = new ReflectionFunction($converter); + } elseif (is_array($converter)) { + $reflection = new ReflectionMethod($converter[0], $converter[1]); + } else { + $reflection = new ReflectionFunction(Closure::fromCallable($converter)); + } + + if ($reflection->getNumberOfParameters() >= 2) { + return $converter; + } + + return static function (mixed $value, MappingContext $context) use ($converter): mixed { + return $converter($value); + }; + } +} diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php new file mode 100644 index 0000000..15af92a --- /dev/null +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -0,0 +1,37 @@ +getTypeIdentifier()->value); + + return $converted; + } +} diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php new file mode 100644 index 0000000..c1537d2 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -0,0 +1,43 @@ +collectionFactory->fromCollectionType($type, $value, $context); + } +} diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php new file mode 100644 index 0000000..31db54f --- /dev/null +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -0,0 +1,40 @@ +registry->has($type->getClassName()); + } + + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + \assert($type instanceof ObjectType); + + return $this->registry->convert($type->getClassName(), $value, $context); + } +} diff --git a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php new file mode 100644 index 0000000..246ae73 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php @@ -0,0 +1,31 @@ +classResolver->resolve($type->getClassName(), $value, $context); + + $mapper = $this->mapper; + + return $mapper($value, $className, $context); + } +} diff --git a/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php new file mode 100644 index 0000000..74aba11 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php @@ -0,0 +1,31 @@ + + */ + private array $strategies = []; + + /** + * Registers the strategy at the end of the chain. + */ + public function addStrategy(ValueConversionStrategyInterface $strategy): void + { + $this->strategies[] = $strategy; + } + + /** + * Converts the value using the first matching strategy. + */ + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports($value, $type, $context)) { + return $strategy->convert($value, $type, $context); + } + } + + throw new LogicException(sprintf('No conversion strategy available for type %s.', $type::class)); + } +} diff --git a/tests/JsonMapper/Context/MappingContextTest.php b/tests/JsonMapper/Context/MappingContextTest.php new file mode 100644 index 0000000..dc8b5ce --- /dev/null +++ b/tests/JsonMapper/Context/MappingContextTest.php @@ -0,0 +1,62 @@ +getPath()); + + $result = $context->withPathSegment('items', function (MappingContext $child): string { + self::assertSame('$.items', $child->getPath()); + + $child->withPathSegment(0, function (MappingContext $nested): void { + self::assertSame('$.items.0', $nested->getPath()); + }); + + return 'done'; + }); + + self::assertSame('done', $result); + self::assertSame('$', $context->getPath()); + } + + #[Test] + public function itCollectsErrors(): void + { + $context = new MappingContext(['root']); + $context->addError('failure'); + + self::assertSame(['failure'], $context->getErrors()); + } + + #[Test] + public function itExposesOptions(): void + { + $context = new MappingContext(['root'], ['flag' => true]); + + self::assertSame(['flag' => true], $context->getOptions()); + self::assertTrue($context->getOption('flag')); + self::assertSame('fallback', $context->getOption('missing', 'fallback')); + } +} diff --git a/tests/JsonMapper/Resolver/ClassResolverTest.php b/tests/JsonMapper/Resolver/ClassResolverTest.php new file mode 100644 index 0000000..f2d0933 --- /dev/null +++ b/tests/JsonMapper/Resolver/ClassResolverTest.php @@ -0,0 +1,57 @@ + 'MappedClass']); + $context = new MappingContext([]); + + self::assertSame('MappedClass', $resolver->resolve('BaseClass', ['json'], $context)); + } + + #[Test] + public function itSupportsClosuresWithSingleArgument(): void + { + $resolver = new ClassResolver(['BaseClass' => static fn (): string => 'FromClosure']); + $context = new MappingContext([]); + + self::assertSame('FromClosure', $resolver->resolve('BaseClass', ['json'], $context)); + } + + #[Test] + public function itSupportsClosuresReceivingContext(): void + { + $resolver = new ClassResolver([ + 'BaseClass' => static function (array $json, MappingContext $context): string { + $context->addError('accessed'); + + return $json['next']; + }, + ]); + $context = new MappingContext([], ['flag' => true]); + + self::assertSame('ResolvedClass', $resolver->resolve('BaseClass', ['next' => 'ResolvedClass'], $context)); + self::assertSame(['accessed'], $context->getErrors()); + } +} diff --git a/tests/JsonMapper/Value/CustomTypeRegistryTest.php b/tests/JsonMapper/Value/CustomTypeRegistryTest.php new file mode 100644 index 0000000..22b57b1 --- /dev/null +++ b/tests/JsonMapper/Value/CustomTypeRegistryTest.php @@ -0,0 +1,51 @@ +register('Foo', static fn (array $value): array => $value); + + $context = new MappingContext([]); + + self::assertTrue($registry->has('Foo')); + self::assertSame(['bar' => 'baz'], $registry->convert('Foo', ['bar' => 'baz'], $context)); + } + + #[Test] + public function itPassesContextToConverters(): void + { + $registry = new CustomTypeRegistry(); + $registry->register('Foo', static function (array $value, MappingContext $context): array { + $context->addError('called'); + + return $value; + }); + + $context = new MappingContext([]); + $registry->convert('Foo', ['payload'], $context); + + self::assertSame(['called'], $context->getErrors()); + } +} From c591d1a93c170071323a71f0e6a338d0bf4e4417 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 21:03:40 +0100 Subject: [PATCH 12/46] Run rector and align mapping type guards --- phpstan-baseline.neon | 13 +- src/JsonMapper.php | 174 +++++++++++------- .../Collection/CollectionFactory.php | 69 +++++-- src/JsonMapper/Context/MappingContext.php | 9 +- src/JsonMapper/Resolver/ClassResolver.php | 44 ++++- src/JsonMapper/Type/TypeResolver.php | 3 +- src/JsonMapper/Value/CustomTypeRegistry.php | 25 +-- .../BuiltinValueConversionStrategy.php | 4 +- .../CollectionValueConversionStrategy.php | 7 +- .../CustomTypeValueConversionStrategy.php | 8 +- .../ObjectValueConversionStrategy.php | 35 +++- tests/Fixtures/Resolver/DummyBaseClass.php | 16 ++ tests/Fixtures/Resolver/DummyMappedClass.php | 16 ++ .../Fixtures/Resolver/DummyResolvedClass.php | 16 ++ .../JsonMapper/Resolver/ClassResolverTest.php | 38 +++- .../Value/CustomTypeRegistryTest.php | 6 +- tests/TestCase.php | 6 +- 17 files changed, 341 insertions(+), 148 deletions(-) create mode 100644 tests/Fixtures/Resolver/DummyBaseClass.php create mode 100644 tests/Fixtures/Resolver/DummyMappedClass.php create mode 100644 tests/Fixtures/Resolver/DummyResolvedClass.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ff6c508..f51e71c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,13 +1,2 @@ parameters: - ignoreErrors: - - - message: '#^Argument of an invalid type array\\|object supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 2 - path: src/JsonMapper.php - - - - message: '#^Argument of an invalid type array\\|object supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 2 - path: src/JsonMapper.php + ignoreErrors: [] diff --git a/src/JsonMapper.php b/src/JsonMapper.php index c39781b..78d3701 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -39,16 +39,19 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Traversable; use function array_key_exists; use function call_user_func_array; use function count; +use function get_object_vars; use function in_array; use function is_array; use function is_callable; use function is_int; use function is_object; use function is_string; +use function iterator_to_array; use function method_exists; use function sprintf; use function ucfirst; @@ -59,9 +62,6 @@ * @author Rico Sonntag * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ - * - * @template TEntity - * @template TEntityCollection */ class JsonMapper { @@ -75,22 +75,27 @@ class JsonMapper private CustomTypeRegistry $customTypeRegistry; + /** + * @param array $classMap + * + * @phpstan-param array $classMap + */ public function __construct( private readonly PropertyInfoExtractorInterface $extractor, private readonly PropertyAccessorInterface $accessor, private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ) { - $this->typeResolver = new TypeResolver($extractor); - $this->classResolver = new ClassResolver($classMap); - $this->customTypeRegistry = new CustomTypeRegistry(); - $this->valueConverter = new ValueConverter(); - $this->collectionFactory = new CollectionFactory( + $this->typeResolver = new TypeResolver($extractor); + $this->classResolver = new ClassResolver($classMap); + $this->customTypeRegistry = new CustomTypeRegistry(); + $this->valueConverter = new ValueConverter(); + $this->collectionFactory = new CollectionFactory( $this->valueConverter, $this->classResolver, function (string $className, ?array $arguments): object { if ($arguments === null) { - return $this->makeInstance($className, null); + return $this->makeInstance($className); } return $this->makeInstance($className, $arguments); @@ -103,9 +108,7 @@ function (string $className, ?array $arguments): object { $this->valueConverter->addStrategy( new ObjectValueConversionStrategy( $this->classResolver, - function (mixed $value, string $resolvedClass, MappingContext $context): mixed { - return $this->map($value, $resolvedClass, null, $context); - }, + fn (mixed $value, string $resolvedClass, MappingContext $context): mixed => $this->map($value, $resolvedClass, null, $context), ), ); $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); @@ -125,9 +128,11 @@ public function addType(string $type, Closure $closure): JsonMapper /** * Add a custom class map entry. * - * @template T + * @param class-string $className + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure * - * @param class-string $className + * @phpstan-param class-string $className + * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure */ public function addCustomClassMapEntry(string $className, Closure $closure): JsonMapper { @@ -139,15 +144,9 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName - * - * @return mixed|TEntityCollection|TEntity|null - * - * @phpstan-return ($collectionClassName is class-string - * ? TEntityCollection - * : ($className is class-string ? TEntity : null|mixed)) + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName * * @throws InvalidArgumentException */ @@ -156,43 +155,50 @@ public function map( ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, - ) { + ): mixed { $context ??= new MappingContext($json); if ($className === null) { return $json; } - $className = $this->classResolver->resolve($className, $json, $context); + /** @var class-string $resolvedClassName */ + $resolvedClassName = $this->classResolver->resolve($className, $json, $context); - if ($collectionClassName !== null) { - $collectionClassName = $this->classResolver->resolve($collectionClassName, $json, $context); - } + /** @var class-string|null $resolvedCollectionClassName */ + $resolvedCollectionClassName = $collectionClassName === null + ? null + : $this->classResolver->resolve($collectionClassName, $json, $context); - $this->assertClassesExists($className, $collectionClassName); + $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); - if ($this->isIterableWithArraysOrObjects($json)) { - if ($collectionClassName !== null) { - $collection = $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); + if (!is_array($json) && !is_object($json)) { + return $this->makeInstance($resolvedClassName); + } - return $this->makeInstance($collectionClassName, $collection); - } + if ( + ($resolvedCollectionClassName !== null) + && $this->isIterableWithArraysOrObjects($json) + ) { + $collection = $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); - if ($this->isNumericIndexArray($json)) { - return $this->collectionFactory->mapIterable($json, new ObjectType($className), $context); - } + return $this->makeInstance($resolvedCollectionClassName, $collection); } - $entity = $this->makeInstance($className); - - if (!is_array($json) && !is_object($json)) { - return $entity; + if ( + $this->isIterableWithArraysOrObjects($json) + && $this->isNumericIndexArray($json) + ) { + return $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); } - $properties = $this->getProperties($className); - $replacePropertyMap = $this->buildReplacePropertyMap($className); + $entity = $this->makeInstance($resolvedClassName); + $source = $this->toIterableArray($json); + + $properties = $this->getProperties($resolvedClassName); + $replacePropertyMap = $this->buildReplacePropertyMap($resolvedClassName); - foreach ($json as $propertyName => $propertyValue) { + foreach ($source as $propertyName => $propertyValue) { $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); if (!is_string($normalizedProperty)) { @@ -204,19 +210,19 @@ public function map( } $context->withPathSegment($normalizedProperty, function (MappingContext $propertyContext) use ( - $className, + $resolvedClassName, $normalizedProperty, $propertyValue, $entity, ): void { - $type = $this->typeResolver->resolve($className, $normalizedProperty, $propertyContext); + $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); $value = $this->convertValue($propertyValue, $type, $propertyContext); if ( ($value === null) - && $this->isReplaceNullWithDefaultValueAnnotation($className, $normalizedProperty) + && $this->isReplaceNullWithDefaultValueAnnotation($resolvedClassName, $normalizedProperty) ) { - $value = $this->getDefaultValue($className, $normalizedProperty); + $value = $this->getDefaultValue($resolvedClassName, $normalizedProperty); } $this->setProperty($entity, $normalizedProperty, $value); @@ -241,24 +247,21 @@ private function convertValue(mixed $json, Type $type, MappingContext $context): /** * Creates an instance of the given class name. * - * @template T of object - * - * @param class-string $className - * @param mixed ...$constructorArguments - * - * @return T + * @param string $className */ - private function makeInstance(string $className, mixed ...$constructorArguments) + private function makeInstance(string $className, mixed ...$constructorArguments): object { - /** @var T $instance */ - $instance = new $className(...$constructorArguments); - - return $instance; + return new $className(...$constructorArguments); } /** * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. */ + /** + * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. + * + * @param class-string $className + */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { return $this->hasPropertyAnnotation( @@ -271,6 +274,8 @@ private function isReplaceNullWithDefaultValueAnnotation(string $className, stri /** * Builds the map of properties replaced by the annotation. * + * @param class-string $className + * * @return array */ private function buildReplacePropertyMap(string $className): array @@ -282,6 +287,10 @@ private function buildReplacePropertyMap(string $className): array continue; } + if (!is_string($annotation->value)) { + continue; + } + $map[$annotation->replaces] = $annotation->value; } @@ -290,6 +299,8 @@ private function buildReplacePropertyMap(string $className): array /** * Normalizes the property name using annotations and converters. + * + * @param array $replacePropertyMap */ private function normalizePropertyName(string|int $propertyName, array $replacePropertyMap): string|int { @@ -300,14 +311,36 @@ private function normalizePropertyName(string|int $propertyName, array $replaceP } if (is_string($normalized) && ($this->nameConverter instanceof PropertyNameConverterInterface)) { - $normalized = $this->nameConverter->convert($normalized); + return $this->nameConverter->convert($normalized); } return $normalized; } + /** + * Converts arrays and objects into a plain array structure. + * + * @param array|object $json + * + * @return array + */ + private function toIterableArray(array|object $json): array + { + if ($json instanceof Traversable) { + return iterator_to_array($json); + } + + if (is_object($json)) { + return get_object_vars($json); + } + + return $json; + } + /** * Returns the specified reflection property. + * + * @param class-string $className */ private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty { @@ -320,6 +353,8 @@ private function getReflectionProperty(string $className, string $propertyName): /** * Returns the specified reflection class. + * + * @param class-string $className */ private function getReflectionClass(string $className): ?ReflectionClass { @@ -333,6 +368,8 @@ private function getReflectionClass(string $className): ?ReflectionClass /** * Extracts possible property annotations. * + * @param class-string $className + * * @return Annotation[]|object[] */ private function extractPropertyAnnotations(string $className, string $propertyName): array @@ -350,6 +387,8 @@ private function extractPropertyAnnotations(string $className, string $propertyN /** * Extracts possible class annotations. * + * @param class-string $className + * * @return Annotation[]|object[] */ private function extractClassAnnotations(string $className): array @@ -366,6 +405,9 @@ private function extractClassAnnotations(string $className): array /** * Returns TRUE if the property has the given annotation. + * + * @param class-string $className + * @param class-string $annotationName */ private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool { @@ -382,6 +424,8 @@ private function hasPropertyAnnotation(string $className, string $propertyName, /** * Returns the default value of a property. + * + * @param class-string $className */ private function getDefaultValue(string $className, string $propertyName): mixed { @@ -396,10 +440,12 @@ private function getDefaultValue(string $className, string $propertyName): mixed /** * Returns TRUE if the given JSON contains integer property keys. + * + * @param array|object $json */ private function isNumericIndexArray(array|object $json): bool { - foreach ($json as $propertyName => $propertyValue) { + foreach (array_keys($this->toIterableArray($json)) as $propertyName) { if (is_int($propertyName)) { return true; } @@ -417,7 +463,9 @@ private function isIterableWithArraysOrObjects(mixed $json): bool return false; } - foreach ($json as $propertyValue) { + $values = is_array($json) ? $json : $this->toIterableArray($json); + + foreach ($values as $propertyValue) { if (is_array($propertyValue)) { continue; } @@ -482,6 +530,8 @@ private function setProperty(object $entity, string $name, mixed $value): void /** * Get all public properties for the specified class. * + * @param class-string $className + * * @return string[] */ private function getProperties(string $className): array diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index e1a7a95..db14ccd 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -12,6 +12,7 @@ namespace MagicSunday\JsonMapper\Collection; use Closure; +use DomainException; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Value\ValueConverter; @@ -19,27 +20,34 @@ use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Traversable; +use function get_object_vars; use function is_array; use function is_object; +use function iterator_to_array; /** * Creates collections and hydrates wrapping collection classes. */ -final class CollectionFactory +final readonly class CollectionFactory { /** - * @param Closure(class-string, array|null):object $instantiator + * @param Closure(class-string, array|null):object $instantiator */ public function __construct( - private readonly ValueConverter $valueConverter, - private readonly ClassResolver $classResolver, - private readonly Closure $instantiator, + private ValueConverter $valueConverter, + private ClassResolver $classResolver, + private Closure $instantiator, ) { } /** * Converts the provided iterable JSON structure to a PHP array. + * + * @param array|object|null $json + * + * @return array|null */ public function mapIterable(array|object|null $json, Type $valueType, MappingContext $context): ?array { @@ -47,16 +55,17 @@ public function mapIterable(array|object|null $json, Type $valueType, MappingCon return null; } - if (!is_array($json) && !is_object($json)) { - return null; - } + /** @var array $source */ + $source = match (true) { + $json instanceof Traversable => iterator_to_array($json), + is_object($json) => get_object_vars($json), + default => $json, + }; $collection = []; - foreach ($json as $key => $value) { - $collection[$key] = $context->withPathSegment((string) $key, function (MappingContext $childContext) use ($valueType, $value): mixed { - return $this->valueConverter->convert($value, $valueType, $childContext); - }); + foreach ($source as $key => $value) { + $collection[$key] = $context->withPathSegment((string) $key, fn (MappingContext $childContext): mixed => $this->valueConverter->convert($value, $valueType, $childContext)); } return $collection; @@ -64,22 +73,48 @@ public function mapIterable(array|object|null $json, Type $valueType, MappingCon /** * Builds a collection based on the specified collection type description. + * + * @return array|object|null */ - public function fromCollectionType(CollectionType $type, array|object|null $json, MappingContext $context): mixed + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed { - $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); + $collection = $this->mapIterable( + is_array($json) || is_object($json) ? $json : null, + $type->getCollectionValueType(), + $context, + ); $wrappedType = $type->getWrappedType(); if (($wrappedType instanceof WrappingTypeInterface) && ($wrappedType->getWrappedType() instanceof ObjectType)) { - $objectType = $wrappedType->getWrappedType(); - $className = $this->classResolver->resolve($objectType->getClassName(), $json, $context); + $objectType = $wrappedType->getWrappedType(); + $className = $this->resolveWrappedClass($objectType); + $resolvedClass = $this->classResolver->resolve($className, $json, $context); $instantiator = $this->instantiator; - return $instantiator($className, $collection); + return $instantiator($resolvedClass, $collection); } return $collection; } + + /** + * Resolves the wrapped collection class name. + * + * @return class-string + * + * @throws DomainException + */ + private function resolveWrappedClass(ObjectType $objectType): string + { + $className = $objectType->getClassName(); + + if ($className === '') { + throw new DomainException('Collection type must define a class-string for the wrapped object.'); + } + + /** @var class-string $className */ + return $className; + } } diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 6927c4c..afcf61a 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -19,7 +19,7 @@ final class MappingContext /** * @var list */ - private array $pathSegments; + private array $pathSegments = []; /** * @var list @@ -30,11 +30,8 @@ final class MappingContext * @param mixed $rootInput The original JSON payload * @param array $options Context options */ - public function __construct( - private readonly mixed $rootInput, - private readonly array $options = [], - ) { - $this->pathSegments = []; + public function __construct(private readonly mixed $rootInput, private readonly array $options = []) + { } /** diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index e38377b..3af1364 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -17,8 +17,11 @@ use ReflectionFunction; use function array_key_exists; +use function class_exists; use function get_debug_type; +use function interface_exists; use function is_string; +use function sprintf; /** * Resolves class names using the configured class map. @@ -26,7 +29,9 @@ final class ClassResolver { /** - * @param array $classMap + * @param array $classMap + * + * @phpstan-param array $classMap */ public function __construct( private array $classMap = [], @@ -35,6 +40,12 @@ public function __construct( /** * Adds a custom resolution rule. + * + * @param class-string $className + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver + * + * @phpstan-param class-string $className + * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver */ public function add(string $className, Closure $resolver): void { @@ -45,19 +56,22 @@ public function add(string $className, Closure $resolver): void * Resolves the class name for the provided JSON payload. * * @param class-string $className + * @param mixed $json * * @return class-string + * + * @throws DomainException */ public function resolve(string $className, mixed $json, MappingContext $context): string { if (!array_key_exists($className, $this->classMap)) { - return $className; + return $this->assertClassString($className); } $mapped = $this->classMap[$className]; if (!($mapped instanceof Closure)) { - return $mapped; + return $this->assertClassString($mapped); } $resolved = $this->invokeResolver($mapped, $json, $context); @@ -72,9 +86,12 @@ public function resolve(string $className, mixed $json, MappingContext $context) ); } - return $resolved; + return $this->assertClassString($resolved); } + /** + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver + */ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $context): mixed { $reflection = new ReflectionFunction($resolver); @@ -85,4 +102,23 @@ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $ return $resolver($json); } + + /** + * @return class-string + * + * @throws DomainException + */ + private function assertClassString(string $className): string + { + if ($className === '') { + throw new DomainException('Resolved class name must not be empty.'); + } + + if (!class_exists($className) && !interface_exists($className)) { + throw new DomainException(sprintf('Resolved class %s does not exist.', $className)); + } + + /** @var class-string $className */ + return $className; + } } diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 7c1b91a..607553a 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -11,7 +11,6 @@ namespace MagicSunday\JsonMapper\Type; -use MagicSunday\JsonMapper\Context\MappingContext; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -34,7 +33,7 @@ public function __construct( /** * Resolves the declared type for the provided property. */ - public function resolve(string $className, string $propertyName, MappingContext $context): Type + public function resolve(string $className, string $propertyName): Type { $type = $this->extractor->getType($className, $propertyName); diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index 0539c7b..84a6174 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -14,10 +14,8 @@ use Closure; use MagicSunday\JsonMapper\Context\MappingContext; use ReflectionFunction; -use ReflectionMethod; use function array_key_exists; -use function is_array; /** * Stores custom conversion handlers keyed by class name. @@ -25,12 +23,14 @@ final class CustomTypeRegistry { /** - * @var array + * @var array */ private array $converters = []; /** * Registers the converter for the provided class name. + * + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter */ public function register(string $className, callable $converter): void { @@ -55,25 +55,16 @@ public function convert(string $className, mixed $value, MappingContext $context /** * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter - * - * @return callable(mixed, MappingContext):mixed */ - private function normalizeConverter(callable $converter): callable + private function normalizeConverter(callable $converter): Closure { - if ($converter instanceof Closure) { - $reflection = new ReflectionFunction($converter); - } elseif (is_array($converter)) { - $reflection = new ReflectionMethod($converter[0], $converter[1]); - } else { - $reflection = new ReflectionFunction(Closure::fromCallable($converter)); - } + $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); + $reflection = new ReflectionFunction($closure); if ($reflection->getNumberOfParameters() >= 2) { - return $converter; + return $closure; } - return static function (mixed $value, MappingContext $context) use ($converter): mixed { - return $converter($value); - }; + return static fn (mixed $value, MappingContext $context): mixed => $closure($value); } } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index 15af92a..c718c53 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -15,6 +15,8 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; +use function assert; + /** * Converts scalar values to the requested builtin type. */ @@ -27,7 +29,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof BuiltinType); + assert($type instanceof BuiltinType); $converted = $value; settype($converted, $type->getTypeIdentifier()->value); diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index c1537d2..44dc1b3 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -16,16 +16,17 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; +use function assert; use function is_array; use function is_object; /** * Converts collection values using the configured factory. */ -final class CollectionValueConversionStrategy implements ValueConversionStrategyInterface +final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface { public function __construct( - private readonly CollectionFactory $collectionFactory, + private CollectionFactory $collectionFactory, ) { } @@ -36,7 +37,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof CollectionType); + assert($type instanceof CollectionType); return $this->collectionFactory->fromCollectionType($type, $value, $context); } diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php index 31db54f..5a9c5fe 100644 --- a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -16,13 +16,15 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; +use function assert; + /** * Handles conversion of registered custom types. */ -final class CustomTypeValueConversionStrategy implements ValueConversionStrategyInterface +final readonly class CustomTypeValueConversionStrategy implements ValueConversionStrategyInterface { public function __construct( - private readonly CustomTypeRegistry $registry, + private CustomTypeRegistry $registry, ) { } @@ -33,7 +35,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof ObjectType); + assert($type instanceof ObjectType); return $this->registry->convert($type->getClassName(), $value, $context); } diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 7153804..3f955d0 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -12,6 +12,7 @@ namespace MagicSunday\JsonMapper\Value\Strategy; use Closure; +use LogicException; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Resolver\ClassResolver; use Symfony\Component\TypeInfo\Type; @@ -20,14 +21,14 @@ /** * Converts object values by delegating to the mapper callback. */ -final class ObjectValueConversionStrategy implements ValueConversionStrategyInterface +final readonly class ObjectValueConversionStrategy implements ValueConversionStrategyInterface { /** - * @param callable(mixed, class-string, MappingContext):mixed $mapper + * @param Closure(mixed, class-string, MappingContext):mixed $mapper */ public function __construct( - private readonly ClassResolver $classResolver, - private readonly Closure $mapper, + private ClassResolver $classResolver, + private Closure $mapper, ) { } @@ -38,12 +39,32 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - \assert($type instanceof ObjectType); + if (!($type instanceof ObjectType)) { + throw new LogicException('ObjectValueConversionStrategy requires an object type.'); + } - $className = $this->classResolver->resolve($type->getClassName(), $value, $context); + $className = $this->resolveClassName($type); + $resolvedClass = $this->classResolver->resolve($className, $value, $context); $mapper = $this->mapper; - return $mapper($value, $className, $context); + return $mapper($value, $resolvedClass, $context); + } + + /** + * Resolves the class name from the provided object type. + * + * @return class-string + */ + private function resolveClassName(ObjectType $type): string + { + $className = $type->getClassName(); + + if ($className === '') { + throw new LogicException('Object type must define a class-string.'); + } + + /** @var class-string $className */ + return $className; } } diff --git a/tests/Fixtures/Resolver/DummyBaseClass.php b/tests/Fixtures/Resolver/DummyBaseClass.php new file mode 100644 index 0000000..c0bea12 --- /dev/null +++ b/tests/Fixtures/Resolver/DummyBaseClass.php @@ -0,0 +1,16 @@ + 'MappedClass']); + $resolver = new ClassResolver([DummyBaseClass::class => DummyMappedClass::class]); $context = new MappingContext([]); - self::assertSame('MappedClass', $resolver->resolve('BaseClass', ['json'], $context)); + self::assertSame(DummyMappedClass::class, $resolver->resolve(DummyBaseClass::class, ['json'], $context)); } #[Test] public function itSupportsClosuresWithSingleArgument(): void { - $resolver = new ClassResolver(['BaseClass' => static fn (): string => 'FromClosure']); + $resolver = new ClassResolver([DummyBaseClass::class => static fn (): string => DummyMappedClass::class]); $context = new MappingContext([]); - self::assertSame('FromClosure', $resolver->resolve('BaseClass', ['json'], $context)); + self::assertSame(DummyMappedClass::class, $resolver->resolve(DummyBaseClass::class, ['json'], $context)); } #[Test] public function itSupportsClosuresReceivingContext(): void { $resolver = new ClassResolver([ - 'BaseClass' => static function (array $json, MappingContext $context): string { + DummyBaseClass::class => static function (mixed $json, MappingContext $context): string { $context->addError('accessed'); - return $json['next']; + return DummyResolvedClass::class; }, ]); $context = new MappingContext([], ['flag' => true]); - self::assertSame('ResolvedClass', $resolver->resolve('BaseClass', ['next' => 'ResolvedClass'], $context)); + self::assertSame(DummyResolvedClass::class, $resolver->resolve(DummyBaseClass::class, ['payload'], $context)); self::assertSame(['accessed'], $context->getErrors()); } + + #[Test] + public function itRejectsResolversReturningNonStrings(): void + { + $resolver = new ClassResolver(); + + $classMap = new ReflectionProperty(ClassResolver::class, 'classMap'); + $classMap->setAccessible(true); + $classMap->setValue($resolver, [ + DummyBaseClass::class => static fn (): int => 123, + ]); + + $context = new MappingContext([]); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Class resolver for ' . DummyBaseClass::class . ' must return a class-string, int given.'); + + $resolver->resolve(DummyBaseClass::class, ['json'], $context); + } } diff --git a/tests/JsonMapper/Value/CustomTypeRegistryTest.php b/tests/JsonMapper/Value/CustomTypeRegistryTest.php index 22b57b1..a9de3f3 100644 --- a/tests/JsonMapper/Value/CustomTypeRegistryTest.php +++ b/tests/JsonMapper/Value/CustomTypeRegistryTest.php @@ -25,7 +25,7 @@ final class CustomTypeRegistryTest extends TestCase public function itNormalizesSingleArgumentClosures(): void { $registry = new CustomTypeRegistry(); - $registry->register('Foo', static fn (array $value): array => $value); + $registry->register('Foo', static fn (mixed $value): array => (array) $value); $context = new MappingContext([]); @@ -37,10 +37,10 @@ public function itNormalizesSingleArgumentClosures(): void public function itPassesContextToConverters(): void { $registry = new CustomTypeRegistry(); - $registry->register('Foo', static function (array $value, MappingContext $context): array { + $registry->register('Foo', static function (mixed $value, MappingContext $context): array { $context->addError('called'); - return $value; + return (array) $value; }); $context = new MappingContext([]); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7a91fa9..960db0d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -32,9 +32,7 @@ class TestCase extends \PHPUnit\Framework\TestCase /** * Returns an instance of the JsonMapper for testing. * - * @param string[]|Closure[] $classMap - * - * @return JsonMapper + * @param array $classMap */ protected function getJsonMapper(array $classMap = []): JsonMapper { @@ -46,7 +44,7 @@ protected function getJsonMapper(array $classMap = []): JsonMapper $extractor, PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), - $classMap + $classMap, ); } From 24fa087719c3fcc835aa0408072a7a064318bd45 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 21:25:05 +0100 Subject: [PATCH 13/46] Add PSR-6 caching for type resolution --- src/JsonMapper.php | 5 +- src/JsonMapper/Resolver/ClassResolver.php | 36 ++- src/JsonMapper/Type/TypeResolver.php | 88 ++++++- tests/JsonMapper/Type/TypeResolverTest.php | 263 +++++++++++++++++++++ 4 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 tests/JsonMapper/Type/TypeResolverTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 78d3701..e2afea8 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -30,6 +30,7 @@ use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; use MagicSunday\JsonMapper\Value\ValueConverter; +use Psr\Cache\CacheItemPoolInterface; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -77,6 +78,7 @@ class JsonMapper /** * @param array $classMap + * @param CacheItemPoolInterface|null $typeCache * * @phpstan-param array $classMap */ @@ -85,8 +87,9 @@ public function __construct( private readonly PropertyAccessorInterface $accessor, private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], + ?CacheItemPoolInterface $typeCache = null, ) { - $this->typeResolver = new TypeResolver($extractor); + $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); $this->customTypeRegistry = new CustomTypeRegistry(); $this->valueConverter = new ValueConverter(); diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index 3af1364..c5d4c18 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -28,14 +28,21 @@ */ final class ClassResolver { + /** + * @var array + * + * @phpstan-var array + */ + private array $classMap; + /** * @param array $classMap * * @phpstan-param array $classMap */ - public function __construct( - private array $classMap = [], - ) { + public function __construct(array $classMap = []) + { + $this->classMap = $this->validateClassMap($classMap); } /** @@ -49,6 +56,7 @@ public function __construct( */ public function add(string $className, Closure $resolver): void { + $this->assertClassString($className); $this->classMap[$className] = $resolver; } @@ -103,6 +111,28 @@ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $ return $resolver($json); } + /** + * Validates the configured class map entries eagerly. + * + * @param array $classMap + * + * @return array + */ + private function validateClassMap(array $classMap): array + { + foreach ($classMap as $sourceClass => $mapping) { + $this->assertClassString($sourceClass); + + if ($mapping instanceof Closure) { + continue; + } + + $this->assertClassString($mapping); + } + + return $classMap; + } + /** * @return class-string * diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 607553a..96c196b 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -11,6 +11,8 @@ namespace MagicSunday\JsonMapper\Type; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -22,25 +24,107 @@ */ final class TypeResolver { + private const CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; + private BuiltinType $defaultType; public function __construct( private readonly PropertyInfoExtractorInterface $extractor, + private readonly ?CacheItemPoolInterface $cache = null, ) { $this->defaultType = new BuiltinType(TypeIdentifier::STRING); } /** * Resolves the declared type for the provided property. + * + * @param class-string $className + * @param string $propertyName + * + * @return Type */ public function resolve(string $className, string $propertyName): Type { + $cached = $this->getCachedType($className, $propertyName); + + if ($cached instanceof Type) { + return $cached; + } + $type = $this->extractor->getType($className, $propertyName); if ($type instanceof UnionType) { - return $type->getTypes()[0]; + $type = $type->getTypes()[0]; + } + + $resolved = $type ?? $this->defaultType; + + $this->storeCachedType($className, $propertyName, $resolved); + + return $resolved; + } + + /** + * Returns a cached type if available. + * + * @param class-string $className + * @param string $propertyName + * + * @return Type|null + */ + private function getCachedType(string $className, string $propertyName): ?Type + { + if ($this->cache === null) { + return null; + } + + try { + $item = $this->cache->getItem($this->buildCacheKey($className, $propertyName)); + } catch (CacheInvalidArgumentException) { + return null; } - return $type ?? $this->defaultType; + if (!$item->isHit()) { + return null; + } + + $cached = $item->get(); + + return $cached instanceof Type ? $cached : null; + } + + /** + * Stores the resolved type in cache when possible. + * + * @param class-string $className + * @param string $propertyName + * @param Type $type + */ + private function storeCachedType(string $className, string $propertyName, Type $type): void + { + if ($this->cache === null) { + return; + } + + try { + $item = $this->cache->getItem($this->buildCacheKey($className, $propertyName)); + $item->set($type); + $this->cache->save($item); + } catch (CacheInvalidArgumentException) { + // Intentionally ignored: caching failures must not block type resolution. + } + } + + /** + * Builds a cache key that fulfils PSR-6 requirements. + * + * @param class-string $className + * @param string $propertyName + * + * @return string + */ + private function buildCacheKey(string $className, string $propertyName): string + { + return self::CACHE_KEY_PREFIX . strtr($className, '\\', '_') . '.' . $propertyName; } } diff --git a/tests/JsonMapper/Type/TypeResolverTest.php b/tests/JsonMapper/Type/TypeResolverTest.php new file mode 100644 index 0000000..5af20a8 --- /dev/null +++ b/tests/JsonMapper/Type/TypeResolverTest.php @@ -0,0 +1,263 @@ +resolve(TypeResolverFixture::class, 'baz'); + $second = $resolver->resolve(TypeResolverFixture::class, 'baz'); + + self::assertSame($first, $second); + self::assertTrue($first->isIdentifiedBy(TypeIdentifier::INT)); + self::assertSame(1, $typeExtractor->callCount); + } + + #[Test] + public function itNormalizesUnionTypesBeforeCaching(): void + { + $typeExtractor = new StubPropertyTypeExtractor( + new UnionType( + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::STRING), + ), + ); + $extractor = new PropertyInfoExtractor([], [$typeExtractor]); + $cache = new InMemoryCachePool(); + $resolver = new TypeResolver($extractor, $cache); + + $type = $resolver->resolve(TypeResolverFixture::class, 'qux'); + + self::assertTrue($type->isIdentifiedBy(TypeIdentifier::INT)); + self::assertSame($type, $resolver->resolve(TypeResolverFixture::class, 'qux')); + self::assertSame(1, $typeExtractor->callCount); + } + + #[Test] + public function itFallsBackToStringType(): void + { + $typeExtractor = new StubPropertyTypeExtractor(null); + $extractor = new PropertyInfoExtractor([], [$typeExtractor]); + $resolver = new TypeResolver($extractor, new InMemoryCachePool()); + + $type = $resolver->resolve(TypeResolverFixture::class, 'name'); + + self::assertInstanceOf(BuiltinType::class, $type); + self::assertTrue($type->isIdentifiedBy(TypeIdentifier::STRING)); + self::assertSame(1, $typeExtractor->callCount); + } +} + +/** + * Lightweight in-memory cache pool implementation for testing purposes only. + */ +final class InMemoryCachePool implements CacheItemPoolInterface +{ + /** + * @var array + */ + private array $items = []; + + public function getItem(string $key): CacheItemInterface + { + if (!array_key_exists($key, $this->items)) { + return new InMemoryCacheItem($key); + } + + return $this->items[$key]; + } + + /** + * @param string[] $keys + * + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + $items = []; + + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + public function hasItem(string $key): bool + { + return array_key_exists($key, $this->items) && $this->items[$key]->isHit(); + } + + public function clear(): bool + { + $this->items = []; + + return true; + } + + public function deleteItem(string $key): bool + { + unset($this->items[$key]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->items[$key]); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item instanceof InMemoryCacheItem + ? $item + : new InMemoryCacheItem($item->getKey(), $item->get(), $item->isHit()); + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function commit(): bool + { + return true; + } +} + +/** + * @internal + */ +final class InMemoryCacheItem implements CacheItemInterface +{ + public function __construct( + private readonly string $key, + private mixed $value = null, + private bool $hit = false, + ) { + } + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->hit; + } + + public function set(mixed $value): static + { + $this->value = $value; + $this->hit = true; + + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static + { + return $this; + } + + public function expiresAfter(DateInterval|int|null $time): static + { + return $this; + } +} + +/** + * Simple type extractor stub that records calls and returns configured types. + */ +final class StubPropertyTypeExtractor implements PropertyTypeExtractorInterface +{ + /** + * @var array + */ + private array $results; + + private int $index = 0; + + public int $callCount = 0; + + public function __construct(?Type ...$results) + { + $this->results = array_values($results); + } + + /** + * @param array $context + */ + public function getType(string $class, string $property, array $context = []): ?Type + { + ++$this->callCount; + + if (!array_key_exists($this->index, $this->results)) { + return null; + } + + return $this->results[$this->index++]; + } + + /** + * @param array $context + */ + public function getTypes(string $class, string $property, array $context = []): ?array + { + return null; + } +} + +/** + * @internal + */ +final class TypeResolverFixture +{ +} From 63648e4b91798dd9be6ac9bf3f43407c2ac8cc2c Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 22:20:48 +0100 Subject: [PATCH 14/46] Refine error handling while preserving legacy context APIs --- src/JsonMapper.php | 157 +++++++++++++- .../Collection/CollectionFactory.php | 27 ++- .../Configuration/MappingConfiguration.php | 73 +++++++ src/JsonMapper/Context/MappingContext.php | 68 +++++- src/JsonMapper/Context/MappingError.php | 42 ++++ .../Exception/CollectionMappingException.php | 33 +++ src/JsonMapper/Exception/MappingException.php | 35 ++++ .../Exception/MissingPropertyException.php | 45 ++++ .../Exception/TypeMismatchException.php | 41 ++++ .../Exception/UnknownPropertyException.php | 45 ++++ src/JsonMapper/Report/MappingReport.php | 45 ++++ src/JsonMapper/Report/MappingResult.php | 34 +++ src/JsonMapper/Type/TypeResolver.php | 76 +++++++ .../BuiltinValueConversionStrategy.php | 66 ++++++ .../CollectionValueConversionStrategy.php | 4 +- .../ObjectValueConversionStrategy.php | 14 ++ .../MappingConfigurationTest.php | 71 +++++++ .../JsonMapperErrorHandlingTest.php | 198 ++++++++++++++++++ tests/JsonMapper/Report/MappingReportTest.php | 46 ++++ tests/JsonMapper/Report/MappingResultTest.php | 38 ++++ 20 files changed, 1128 insertions(+), 30 deletions(-) create mode 100644 src/JsonMapper/Configuration/MappingConfiguration.php create mode 100644 src/JsonMapper/Context/MappingError.php create mode 100644 src/JsonMapper/Exception/CollectionMappingException.php create mode 100644 src/JsonMapper/Exception/MappingException.php create mode 100644 src/JsonMapper/Exception/MissingPropertyException.php create mode 100644 src/JsonMapper/Exception/TypeMismatchException.php create mode 100644 src/JsonMapper/Exception/UnknownPropertyException.php create mode 100644 src/JsonMapper/Report/MappingReport.php create mode 100644 src/JsonMapper/Report/MappingResult.php create mode 100644 tests/JsonMapper/Configuration/MappingConfigurationTest.php create mode 100644 tests/JsonMapper/JsonMapperErrorHandlingTest.php create mode 100644 tests/JsonMapper/Report/MappingReportTest.php create mode 100644 tests/JsonMapper/Report/MappingResultTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index e2afea8..34072e6 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -18,8 +18,14 @@ use MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue; use MagicSunday\JsonMapper\Annotation\ReplaceProperty; use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; +use MagicSunday\JsonMapper\Exception\MappingException; +use MagicSunday\JsonMapper\Exception\MissingPropertyException; +use MagicSunday\JsonMapper\Exception\UnknownPropertyException; +use MagicSunday\JsonMapper\Report\MappingReport; +use MagicSunday\JsonMapper\Report\MappingResult; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Type\TypeResolver; use MagicSunday\JsonMapper\Value\CustomTypeRegistry; @@ -34,7 +40,9 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; +use ReflectionNamedType; use ReflectionProperty; +use ReflectionUnionType; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; @@ -42,7 +50,11 @@ use Symfony\Component\TypeInfo\Type\ObjectType; use Traversable; +use function array_diff; +use function array_filter; use function array_key_exists; +use function array_unique; +use function array_values; use function call_user_func_array; use function count; use function get_object_vars; @@ -111,7 +123,11 @@ function (string $className, ?array $arguments): object { $this->valueConverter->addStrategy( new ObjectValueConversionStrategy( $this->classResolver, - fn (mixed $value, string $resolvedClass, MappingContext $context): mixed => $this->map($value, $resolvedClass, null, $context), + function (mixed $value, string $resolvedClass, MappingContext $context): mixed { + $configuration = MappingConfiguration::fromContext($context); + + return $this->map($value, $resolvedClass, null, $context, $configuration); + }, ), ); $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); @@ -158,8 +174,18 @@ public function map( ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, + ?MappingConfiguration $configuration = null, ): mixed { - $context ??= new MappingContext($json); + if ($context === null) { + $configuration ??= MappingConfiguration::lenient(); + $context = new MappingContext($json, $configuration->toOptions()); + } else { + if ($configuration === null) { + $configuration = MappingConfiguration::fromContext($context); + } else { + $context->replaceOptions($configuration->toOptions()); + } + } if ($className === null) { return $json; @@ -200,24 +226,37 @@ public function map( $properties = $this->getProperties($resolvedClassName); $replacePropertyMap = $this->buildReplacePropertyMap($resolvedClassName); + $mappedProperties = []; foreach ($source as $propertyName => $propertyValue) { $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); + $pathSegment = is_string($normalizedProperty) ? $normalizedProperty : (string) $propertyName; - if (!is_string($normalizedProperty)) { - continue; - } - - if (!in_array($normalizedProperty, $properties, true)) { - continue; - } - - $context->withPathSegment($normalizedProperty, function (MappingContext $propertyContext) use ( + $context->withPathSegment($pathSegment, function (MappingContext $propertyContext) use ( $resolvedClassName, $normalizedProperty, $propertyValue, $entity, + &$mappedProperties, + $properties, + $configuration, ): void { + if (!is_string($normalizedProperty)) { + return; + } + + if (!in_array($normalizedProperty, $properties, true)) { + $this->handleMappingException( + new UnknownPropertyException($propertyContext->getPath(), $normalizedProperty, $resolvedClassName), + $propertyContext, + $configuration, + ); + + return; + } + + $mappedProperties[] = $normalizedProperty; + $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); $value = $this->convertValue($propertyValue, $type, $propertyContext); @@ -232,9 +271,105 @@ public function map( }); } + if ($configuration->isStrictMode()) { + foreach ($this->determineMissingProperties($resolvedClassName, $properties, $mappedProperties) as $missingProperty) { + $context->withPathSegment($missingProperty, function (MappingContext $propertyContext) use ( + $resolvedClassName, + $missingProperty, + $configuration, + ): void { + $this->handleMappingException( + new MissingPropertyException($propertyContext->getPath(), $missingProperty, $resolvedClassName), + $propertyContext, + $configuration, + ); + }); + } + } + return $entity; } + /** + * Maps the JSON structure and returns a detailed mapping report. + * + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName + */ + public function mapWithReport( + mixed $json, + ?string $className = null, + ?string $collectionClassName = null, + ?MappingConfiguration $configuration = null, + ): MappingResult { + $configuration = ($configuration ?? MappingConfiguration::lenient())->withErrorCollection(true); + $context = new MappingContext($json, $configuration->toOptions()); + $value = $this->map($json, $className, $collectionClassName, $context, $configuration); + + return new MappingResult($value, new MappingReport($context->getErrorRecords())); + } + + /** + * @param class-string $className + * @param array $declaredProperties + * @param list $mappedProperties + * + * @return list + */ + private function determineMissingProperties(string $className, array $declaredProperties, array $mappedProperties): array + { + $used = array_values(array_unique($mappedProperties)); + + return array_values(array_filter( + array_diff($declaredProperties, $used), + fn (string $property): bool => $this->isRequiredProperty($className, $property), + )); + } + + /** + * @param class-string $className + */ + private function isRequiredProperty(string $className, string $propertyName): bool + { + $reflectionProperty = $this->getReflectionProperty($className, $propertyName); + + if (!($reflectionProperty instanceof ReflectionProperty)) { + return false; + } + + if ($reflectionProperty->hasDefaultValue()) { + return false; + } + + $type = $reflectionProperty->getType(); + + if ($type instanceof ReflectionNamedType) { + return !$type->allowsNull(); + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $innerType) { + if ($innerType instanceof ReflectionNamedType && $innerType->allowsNull()) { + return false; + } + } + + return true; + } + + return false; + } + + private function handleMappingException(MappingException $exception, MappingContext $context, MappingConfiguration $configuration): void + { + $context->recordException($exception); + + if ($configuration->isStrictMode()) { + throw $exception; + } + } + /** * Converts the provided JSON value using the registered strategies. */ diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index db14ccd..c37b69f 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -14,6 +14,7 @@ use Closure; use DomainException; use MagicSunday\JsonMapper\Context\MappingContext; +use MagicSunday\JsonMapper\Exception\CollectionMappingException; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Value\ValueConverter; use Symfony\Component\TypeInfo\Type; @@ -22,6 +23,7 @@ use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Traversable; +use function get_debug_type; use function get_object_vars; use function is_array; use function is_object; @@ -45,23 +47,32 @@ public function __construct( /** * Converts the provided iterable JSON structure to a PHP array. * - * @param array|object|null $json - * * @return array|null */ - public function mapIterable(array|object|null $json, Type $valueType, MappingContext $context): ?array + public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array { if ($json === null) { return null; } - /** @var array $source */ $source = match (true) { $json instanceof Traversable => iterator_to_array($json), + is_array($json) => $json, is_object($json) => get_object_vars($json), - default => $json, + default => null, }; + if (!is_array($source)) { + $exception = new CollectionMappingException($context->getPath(), get_debug_type($json)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + + return null; + } + $collection = []; foreach ($source as $key => $value) { @@ -78,11 +89,7 @@ public function mapIterable(array|object|null $json, Type $valueType, MappingCon */ public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed { - $collection = $this->mapIterable( - is_array($json) || is_object($json) ? $json : null, - $type->getCollectionValueType(), - $context, - ); + $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); $wrappedType = $type->getWrappedType(); diff --git a/src/JsonMapper/Configuration/MappingConfiguration.php b/src/JsonMapper/Configuration/MappingConfiguration.php new file mode 100644 index 0000000..794e91c --- /dev/null +++ b/src/JsonMapper/Configuration/MappingConfiguration.php @@ -0,0 +1,73 @@ +isStrictMode(), + $context->shouldCollectErrors(), + ); + } + + public function withErrorCollection(bool $collect): self + { + $clone = clone $this; + $clone->collectErrors = $collect; + + return $clone; + } + + public function isStrictMode(): bool + { + return $this->strictMode; + } + + public function shouldCollectErrors(): bool + { + return $this->collectErrors; + } + + /** + * @return array + */ + public function toOptions(): array + { + return [ + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + ]; + } +} diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index afcf61a..8cf2959 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -11,27 +11,38 @@ namespace MagicSunday\JsonMapper\Context; +use MagicSunday\JsonMapper\Exception\MappingException; + /** * Represents the state shared while mapping JSON structures. */ final class MappingContext { + public const OPTION_STRICT_MODE = 'strict_mode'; + public const OPTION_COLLECT_ERRORS = 'collect_errors'; + /** * @var list */ private array $pathSegments = []; /** - * @var list + * @var list + */ + private array $errorRecords = []; + + /** + * @var array */ - private array $errors = []; + private array $options; /** * @param mixed $rootInput The original JSON payload * @param array $options Context options */ - public function __construct(private readonly mixed $rootInput, private readonly array $options = []) + public function __construct(private readonly mixed $rootInput, array $options = []) { + $this->options = $options; } /** @@ -73,9 +84,21 @@ public function withPathSegment(string|int $segment, callable $callback): mixed /** * Stores the error message for later consumption. */ - public function addError(string $message): void + public function addError(string $message, ?MappingException $exception = null): void + { + if (!$this->shouldCollectErrors()) { + return; + } + + $this->errorRecords[] = new MappingError($this->getPath(), $message, $exception); + } + + /** + * Stores the exception and message for later consumption. + */ + public function recordException(MappingException $exception): void { - $this->errors[] = $message; + $this->addError($exception->getMessage(), $exception); } /** @@ -85,7 +108,20 @@ public function addError(string $message): void */ public function getErrors(): array { - return $this->errors; + return array_map( + static fn (MappingError $error): string => $error->getMessage(), + $this->errorRecords, + ); + } + + public function shouldCollectErrors(): bool + { + return (bool) ($this->options[self::OPTION_COLLECT_ERRORS] ?? true); + } + + public function isStrictMode(): bool + { + return (bool) ($this->options[self::OPTION_STRICT_MODE] ?? false); } /** @@ -105,4 +141,24 @@ public function getOption(string $name, mixed $default = null): mixed { return $this->options[$name] ?? $default; } + + /** + * Replaces the stored options. + * + * @param array $options + */ + public function replaceOptions(array $options): void + { + $this->options = $options; + } + + /** + * Returns collected mapping errors with contextual details. + * + * @return list + */ + public function getErrorRecords(): array + { + return $this->errorRecords; + } } diff --git a/src/JsonMapper/Context/MappingError.php b/src/JsonMapper/Context/MappingError.php new file mode 100644 index 0000000..6bcc624 --- /dev/null +++ b/src/JsonMapper/Context/MappingError.php @@ -0,0 +1,42 @@ +path; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getException(): ?MappingException + { + return $this->exception; + } +} diff --git a/src/JsonMapper/Exception/CollectionMappingException.php b/src/JsonMapper/Exception/CollectionMappingException.php new file mode 100644 index 0000000..22824db --- /dev/null +++ b/src/JsonMapper/Exception/CollectionMappingException.php @@ -0,0 +1,33 @@ +actualType; + } +} diff --git a/src/JsonMapper/Exception/MappingException.php b/src/JsonMapper/Exception/MappingException.php new file mode 100644 index 0000000..6327a43 --- /dev/null +++ b/src/JsonMapper/Exception/MappingException.php @@ -0,0 +1,35 @@ +path; + } +} diff --git a/src/JsonMapper/Exception/MissingPropertyException.php b/src/JsonMapper/Exception/MissingPropertyException.php new file mode 100644 index 0000000..bc3344d --- /dev/null +++ b/src/JsonMapper/Exception/MissingPropertyException.php @@ -0,0 +1,45 @@ +propertyName; + } + + /** + * @return class-string + */ + public function getClassName(): string + { + return $this->className; + } +} diff --git a/src/JsonMapper/Exception/TypeMismatchException.php b/src/JsonMapper/Exception/TypeMismatchException.php new file mode 100644 index 0000000..4c76130 --- /dev/null +++ b/src/JsonMapper/Exception/TypeMismatchException.php @@ -0,0 +1,41 @@ +expectedType; + } + + public function getActualType(): string + { + return $this->actualType; + } +} diff --git a/src/JsonMapper/Exception/UnknownPropertyException.php b/src/JsonMapper/Exception/UnknownPropertyException.php new file mode 100644 index 0000000..2d81044 --- /dev/null +++ b/src/JsonMapper/Exception/UnknownPropertyException.php @@ -0,0 +1,45 @@ +propertyName; + } + + /** + * @return class-string + */ + public function getClassName(): string + { + return $this->className; + } +} diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php new file mode 100644 index 0000000..9195094 --- /dev/null +++ b/src/JsonMapper/Report/MappingReport.php @@ -0,0 +1,45 @@ + $errors + */ + public function __construct(private readonly array $errors) + { + } + + /** + * @return list + */ + public function getErrors(): array + { + return $this->errors; + } + + public function hasErrors(): bool + { + return $this->errors !== []; + } + + public function getErrorCount(): int + { + return count($this->errors); + } +} diff --git a/src/JsonMapper/Report/MappingResult.php b/src/JsonMapper/Report/MappingResult.php new file mode 100644 index 0000000..0861e22 --- /dev/null +++ b/src/JsonMapper/Report/MappingResult.php @@ -0,0 +1,34 @@ +value; + } + + public function getReport(): MappingReport + { + return $this->report; + } +} diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 96c196b..e7aa5d9 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -13,6 +13,10 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; +use ReflectionException; +use ReflectionNamedType; +use ReflectionProperty; +use ReflectionUnionType; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -57,6 +61,10 @@ public function resolve(string $className, string $propertyName): Type $type = $type->getTypes()[0]; } + if ($type === null) { + $type = $this->resolveFromReflection($className, $propertyName); + } + $resolved = $type ?? $this->defaultType; $this->storeCachedType($className, $propertyName, $resolved); @@ -127,4 +135,72 @@ private function buildCacheKey(string $className, string $propertyName): string { return self::CACHE_KEY_PREFIX . strtr($className, '\\', '_') . '.' . $propertyName; } + + /** + * @param class-string $className + */ + private function resolveFromReflection(string $className, string $propertyName): ?Type + { + try { + $property = new ReflectionProperty($className, $propertyName); + } catch (ReflectionException) { + return null; + } + + $reflectionType = $property->getType(); + + if ($reflectionType instanceof ReflectionNamedType) { + return $this->createTypeFromNamedReflection($reflectionType); + } + + if ($reflectionType instanceof ReflectionUnionType) { + $allowsNull = false; + $primary = null; + + foreach ($reflectionType->getTypes() as $innerType) { + if (!$innerType instanceof ReflectionNamedType) { + continue; + } + + if ($innerType->getName() === 'null') { + $allowsNull = true; + + continue; + } + + $primary ??= $innerType; + } + + if ($primary instanceof ReflectionNamedType) { + return $this->createTypeFromNamedReflection($primary, $allowsNull || $primary->allowsNull()); + } + } + + return null; + } + + private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool $nullable = null): ?Type + { + $name = $type->getName(); + + if ($type->isBuiltin()) { + $identifier = TypeIdentifier::tryFrom($name); + + if ($identifier === null) { + return null; + } + + $resolved = Type::builtin($identifier); + } else { + $resolved = Type::object($name); + } + + $allowsNull = $nullable ?? $type->allowsNull(); + + if ($allowsNull) { + return Type::nullable($resolved); + } + + return $resolved; + } } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index c718c53..cf0d1c5 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -12,10 +12,22 @@ namespace MagicSunday\JsonMapper\Value\Strategy; use MagicSunday\JsonMapper\Context\MappingContext; +use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Traversable; use function assert; +use function get_debug_type; +use function is_array; +use function is_bool; +use function is_callable; +use function is_float; +use function is_int; +use function is_object; +use function is_string; +use function settype; /** * Converts scalar values to the requested builtin type. @@ -31,9 +43,63 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe { assert($type instanceof BuiltinType); + $this->guardCompatibility($value, $type, $context); + $converted = $value; settype($converted, $type->getTypeIdentifier()->value); return $converted; } + + private function guardCompatibility(mixed $value, BuiltinType $type, MappingContext $context): void + { + $identifier = $type->getTypeIdentifier(); + + if ($value === null) { + if ($this->allowsNull($type)) { + return; + } + + $exception = new TypeMismatchException($context->getPath(), $identifier->value, 'null'); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + + return; + } + + if ($this->isCompatibleValue($value, $identifier)) { + return; + } + + $exception = new TypeMismatchException($context->getPath(), $identifier->value, get_debug_type($value)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + } + + private function allowsNull(BuiltinType $type): bool + { + return $type->isNullable(); + } + + private function isCompatibleValue(mixed $value, TypeIdentifier $identifier): bool + { + return match ($identifier->value) { + 'int' => is_int($value), + 'float' => is_float($value) || is_int($value), + 'bool' => is_bool($value), + 'string' => is_string($value), + 'array' => is_array($value), + 'object' => is_object($value), + 'callable' => is_callable($value), + 'iterable' => is_array($value) || $value instanceof Traversable, + 'null' => $value === null, + default => true, + }; + } } diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index 44dc1b3..6fdff27 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -17,8 +17,6 @@ use Symfony\Component\TypeInfo\Type\CollectionType; use function assert; -use function is_array; -use function is_object; /** * Converts collection values using the configured factory. @@ -32,7 +30,7 @@ public function __construct( public function supports(mixed $value, Type $type, MappingContext $context): bool { - return ($type instanceof CollectionType) && (is_array($value) || is_object($value) || $value === null); + return $type instanceof CollectionType; } public function convert(mixed $value, Type $type, MappingContext $context): mixed diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 3f955d0..1fcc88c 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -14,10 +14,15 @@ use Closure; use LogicException; use MagicSunday\JsonMapper\Context\MappingContext; +use MagicSunday\JsonMapper\Exception\TypeMismatchException; use MagicSunday\JsonMapper\Resolver\ClassResolver; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; +use function get_debug_type; +use function is_array; +use function is_object; + /** * Converts object values by delegating to the mapper callback. */ @@ -46,6 +51,15 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe $className = $this->resolveClassName($type); $resolvedClass = $this->classResolver->resolve($className, $value, $context); + if (($value !== null) && !is_array($value) && !is_object($value)) { + $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + } + $mapper = $this->mapper; return $mapper($value, $resolvedClass, $context); diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php new file mode 100644 index 0000000..971a479 --- /dev/null +++ b/tests/JsonMapper/Configuration/MappingConfigurationTest.php @@ -0,0 +1,71 @@ +isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + } + + #[Test] + public function itEnablesStrictMode(): void + { + $configuration = MappingConfiguration::strict(); + + self::assertTrue($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + } + + #[Test] + public function itSupportsTogglingErrorCollection(): void + { + $configuration = MappingConfiguration::lenient()->withErrorCollection(false); + + self::assertFalse($configuration->isStrictMode()); + self::assertFalse($configuration->shouldCollectErrors()); + } + + #[Test] + public function itDerivesFromContext(): void + { + $context = new MappingContext([], [ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + ]); + + $configuration = MappingConfiguration::fromContext($context); + + self::assertTrue($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertSame( + [ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + ], + $configuration->toOptions(), + ); + } +} diff --git a/tests/JsonMapper/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php new file mode 100644 index 0000000..793138d --- /dev/null +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -0,0 +1,198 @@ +getJsonMapper() + ->mapWithReport([ + 'name' => 'John Doe', + 'unknown' => 'value', + ], Person::class); + + self::assertInstanceOf(Person::class, $result->getValue()); + + $report = $result->getReport(); + self::assertTrue($report->hasErrors()); + self::assertSame(1, $report->getErrorCount()); + + $error = $report->getErrors()[0]; + self::assertSame('Unknown property $.unknown on ' . Person::class . '.', $error->getMessage()); + self::assertInstanceOf(UnknownPropertyException::class, $error->getException()); + } + + #[Test] + public function itThrowsOnUnknownPropertiesInStrictMode(): void + { + $this->expectException(UnknownPropertyException::class); + + $this->getJsonMapper() + ->map( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnMissingRequiredProperties(): void + { + $this->expectException(MissingPropertyException::class); + + $this->getJsonMapper() + ->map( + [], + Person::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnTypeMismatch(): void + { + $this->expectException(TypeMismatchException::class); + + $this->getJsonMapper() + ->map( + ['name' => 123], + Base::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnInvalidCollectionPayloads(): void + { + $this->expectException(CollectionMappingException::class); + + $this->getJsonMapper() + ->map( + [ + 'name' => 'John Doe', + 'simpleArray' => 'invalid', + ], + Base::class, + null, + null, + MappingConfiguration::strict(), + ); + } + + #[Test] + public function itReportsTypeMismatchesInLenientMode(): void + { + $result = $this->getJsonMapper() + ->mapWithReport( + ['name' => 123], + Base::class, + ); + + $report = $result->getReport(); + self::assertTrue($report->hasErrors()); + + $exception = $report->getErrors()[0]->getException(); + self::assertInstanceOf(TypeMismatchException::class, $exception); + } + + #[Test] + public function itCollectsNestedErrorsAcrossObjectGraphs(): void + { + $result = $this->getJsonMapper() + ->mapWithReport( + [ + 'simple' => [ + 'int' => 'oops', + 'name' => 456, + 'unknown' => 'value', + ], + ], + Base::class, + ); + + $errors = $result->getReport()->getErrors(); + + self::assertCount(3, $errors); + + $errorsByPath = []; + foreach ($errors as $error) { + $errorsByPath[$error->getPath()] = $error; + } + + self::assertArrayHasKey('$.simple.int', $errorsByPath); + self::assertSame( + 'Type mismatch at $.simple.int: expected int, got string.', + $errorsByPath['$.simple.int']->getMessage(), + ); + self::assertInstanceOf(TypeMismatchException::class, $errorsByPath['$.simple.int']->getException()); + + self::assertArrayHasKey('$.simple.name', $errorsByPath); + self::assertSame( + 'Type mismatch at $.simple.name: expected string, got int.', + $errorsByPath['$.simple.name']->getMessage(), + ); + self::assertInstanceOf(TypeMismatchException::class, $errorsByPath['$.simple.name']->getException()); + + self::assertArrayHasKey('$.simple.unknown', $errorsByPath); + self::assertSame( + 'Unknown property $.simple.unknown on ' . Simple::class . '.', + $errorsByPath['$.simple.unknown']->getMessage(), + ); + self::assertInstanceOf(UnknownPropertyException::class, $errorsByPath['$.simple.unknown']->getException()); + } + + #[Test] + public function itThrowsOnInvalidNestedCollectionEntriesInStrictMode(): void + { + $this->expectException(TypeMismatchException::class); + $this->expectExceptionMessage('Type mismatch at $.simpleArray.1.int: expected int, got string.'); + + $this->getJsonMapper() + ->map( + [ + 'simpleArray' => [ + ['id' => 1, 'int' => 1, 'name' => 'Valid'], + ['id' => 2, 'int' => 'oops', 'name' => 'Broken'], + ], + ], + Base::class, + null, + null, + MappingConfiguration::strict(), + ); + } +} diff --git a/tests/JsonMapper/Report/MappingReportTest.php b/tests/JsonMapper/Report/MappingReportTest.php new file mode 100644 index 0000000..8f765c9 --- /dev/null +++ b/tests/JsonMapper/Report/MappingReportTest.php @@ -0,0 +1,46 @@ +hasErrors()); + self::assertSame(1, $report->getErrorCount()); + self::assertSame($errors, $report->getErrors()); + } + + #[Test] + public function itHandlesEmptyReports(): void + { + $report = new MappingReport([]); + + self::assertFalse($report->hasErrors()); + self::assertSame(0, $report->getErrorCount()); + } +} diff --git a/tests/JsonMapper/Report/MappingResultTest.php b/tests/JsonMapper/Report/MappingResultTest.php new file mode 100644 index 0000000..0583e3c --- /dev/null +++ b/tests/JsonMapper/Report/MappingResultTest.php @@ -0,0 +1,38 @@ + 'bar'], $report); + + self::assertSame(['foo' => 'bar'], $result->getValue()); + self::assertSame($report, $result->getReport()); + } +} From 4acdb90d2073512fcc72cca7c9f80a4015c01d45 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 22:48:38 +0100 Subject: [PATCH 15/46] Add PHP 8+ attribute support and modern type conversions --- README.md | 19 +- composer.json | 3 +- src/JsonMapper.php | 274 +++++++++++++----- .../ReplaceNullWithDefaultValue.php | 30 -- src/JsonMapper/Annotation/ReplaceProperty.php | 36 --- .../Attribute/ReplaceNullWithDefaultValue.php | 22 ++ src/JsonMapper/Attribute/ReplaceProperty.php | 27 ++ .../Configuration/MappingConfiguration.php | 20 +- src/JsonMapper/Context/MappingContext.php | 18 +- .../Exception/ReadonlyPropertyException.php | 28 ++ src/JsonMapper/Type/TypeResolver.php | 72 ++++- .../BuiltinValueConversionStrategy.php | 76 ++++- .../DateTimeValueConversionStrategy.php | 87 ++++++ .../Strategy/EnumValueConversionStrategy.php | 80 +++++ .../ReplacePropertyTest.php | 2 +- tests/Classes/DateTimeHolder.php | 22 ++ tests/Classes/EnumHolder.php | 19 ++ tests/Classes/Initialized.php | 11 +- tests/Classes/NullableStringHolder.php | 17 ++ tests/Classes/ReadonlyEntity.php | 22 ++ tests/Classes/ReplacePropertyTestClass.php | 7 +- tests/Classes/ScalarHolder.php | 21 ++ tests/Classes/UnionHolder.php | 19 ++ tests/Fixtures/Enum/SampleStatus.php | 18 ++ .../MappingConfigurationTest.php | 13 + .../JsonMapperErrorHandlingTest.php | 21 ++ tests/JsonMapperTest.php | 117 +++++++- 27 files changed, 914 insertions(+), 187 deletions(-) delete mode 100644 src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php delete mode 100644 src/JsonMapper/Annotation/ReplaceProperty.php create mode 100644 src/JsonMapper/Attribute/ReplaceNullWithDefaultValue.php create mode 100644 src/JsonMapper/Attribute/ReplaceProperty.php create mode 100644 src/JsonMapper/Exception/ReadonlyPropertyException.php create mode 100644 src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php create mode 100644 src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php rename tests/{Annotation => Attribute}/ReplacePropertyTest.php (97%) create mode 100644 tests/Classes/DateTimeHolder.php create mode 100644 tests/Classes/EnumHolder.php create mode 100644 tests/Classes/NullableStringHolder.php create mode 100644 tests/Classes/ReadonlyEntity.php create mode 100644 tests/Classes/ScalarHolder.php create mode 100644 tests/Classes/UnionHolder.php create mode 100644 tests/Fixtures/Enum/SampleStatus.php diff --git a/README.md b/README.md index f39b5e5..6e53d9e 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,13 @@ For example: ``` -#### Custom annotations +#### Custom attributes Sometimes its may be required to circumvent the limitations of a poorly designed API. Together with custom -annotations it becomes possible to fix some API design issues (e.g. mismatch between documentation and webservice +attributes it becomes possible to fix some API design issues (e.g. mismatch between documentation and webservice response), to create a clean SDK. -##### @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue -This annotation is used to inform the JsonMapper that an existing default value should be used when +##### #[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] +This attribute is used to inform the JsonMapper that an existing default value should be used when setting a property, if the value derived from the JSON is a NULL value instead of the expected property type. This can be necessary, for example, in the case of a bad API design, if the API documentation defines a @@ -54,23 +54,20 @@ instead of an empty array that can be expected. ```php /** * @var array - * - * @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue */ +#[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] public array $array = []; ``` If the mapping tries to assign NULL to the property, the default value will be used, as annotated. -##### @MagicSunday\JsonMapper\Annotation\ReplaceProperty -This annotation is used to inform the JsonMapper to replace one or more properties with another one. It's +##### #[MagicSunday\JsonMapper\Attribute\ReplaceProperty] +This attribute is used to inform the JsonMapper to replace one or more properties with another one. It's used in class context. For instance if you want to replace a cryptic named property to a more human-readable name. ```php -/** - * @MagicSunday\JsonMapper\Annotation\ReplaceProperty("type", replaces="crypticTypeNameProperty") - */ +#[MagicSunday\JsonMapper\Attribute\ReplaceProperty('type', replaces: 'crypticTypeNameProperty')] class FooClass { /** diff --git a/composer.json b/composer.json index 3fdf156..9d700ef 100644 --- a/composer.json +++ b/composer.json @@ -19,8 +19,7 @@ "symfony/property-info": "^7.3", "symfony/property-access": "^7.3", "symfony/type-info": "^7.3", - "doctrine/inflector": "^2.0", - "doctrine/annotations": "^2.0" + "doctrine/inflector": "^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.65", diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 34072e6..675b096 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -12,17 +12,17 @@ namespace MagicSunday; use Closure; -use Doctrine\Common\Annotations\Annotation; -use Doctrine\Common\Annotations\AnnotationReader; use InvalidArgumentException; -use MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue; -use MagicSunday\JsonMapper\Annotation\ReplaceProperty; +use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; +use MagicSunday\JsonMapper\Attribute\ReplaceProperty; use MagicSunday\JsonMapper\Collection\CollectionFactory; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; use MagicSunday\JsonMapper\Exception\MappingException; use MagicSunday\JsonMapper\Exception\MissingPropertyException; +use MagicSunday\JsonMapper\Exception\ReadonlyPropertyException; +use MagicSunday\JsonMapper\Exception\TypeMismatchException; use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\JsonMapper\Report\MappingReport; use MagicSunday\JsonMapper\Report\MappingResult; @@ -32,11 +32,14 @@ use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\CollectionValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\CustomTypeValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\DateTimeValueConversionStrategy; +use MagicSunday\JsonMapper\Value\Strategy\EnumValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\NullValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; use MagicSunday\JsonMapper\Value\ValueConverter; use Psr\Cache\CacheItemPoolInterface; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -46,8 +49,11 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; use Traversable; use function array_diff; @@ -57,7 +63,9 @@ use function array_values; use function call_user_func_array; use function count; +use function get_debug_type; use function get_object_vars; +use function implode; use function in_array; use function is_array; use function is_callable; @@ -67,6 +75,7 @@ use function iterator_to_array; use function method_exists; use function sprintf; +use function trim; use function ucfirst; /** @@ -120,6 +129,8 @@ function (string $className, ?array $arguments): object { $this->valueConverter->addStrategy(new NullValueConversionStrategy()); $this->valueConverter->addStrategy(new CollectionValueConversionStrategy($this->collectionFactory)); $this->valueConverter->addStrategy(new CustomTypeValueConversionStrategy($this->customTypeRegistry)); + $this->valueConverter->addStrategy(new DateTimeValueConversionStrategy()); + $this->valueConverter->addStrategy(new EnumValueConversionStrategy()); $this->valueConverter->addStrategy( new ObjectValueConversionStrategy( $this->classResolver, @@ -257,8 +268,15 @@ public function map( $mappedProperties[] = $normalizedProperty; - $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); - $value = $this->convertValue($propertyValue, $type, $propertyContext); + $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); + + try { + $value = $this->convertValue($propertyValue, $type, $propertyContext); + } catch (MappingException $exception) { + $this->handleMappingException($exception, $propertyContext, $configuration); + + return; + } if ( ($value === null) @@ -267,7 +285,11 @@ public function map( $value = $this->getDefaultValue($resolvedClassName, $normalizedProperty); } - $this->setProperty($entity, $normalizedProperty, $value); + try { + $this->setProperty($entity, $normalizedProperty, $value, $propertyContext); + } catch (ReadonlyPropertyException $exception) { + $this->handleMappingException($exception, $propertyContext, $configuration); + } }); } @@ -375,13 +397,148 @@ private function handleMappingException(MappingException $exception, MappingCont */ private function convertValue(mixed $json, Type $type, MappingContext $context): mixed { + if ( + is_string($json) + && ($json === '' || trim($json) === '') + && (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false) + ) { + $json = null; + } + if ($type instanceof CollectionType) { return $this->collectionFactory->fromCollectionType($type, $json, $context); } + if ($type instanceof UnionType) { + return $this->convertUnionValue($json, $type, $context); + } + + if ($this->isNullType($type)) { + return null; + } + return $this->valueConverter->convert($json, $type, $context); } + /** + * Converts the value according to the provided union type. + */ + private function convertUnionValue(mixed $json, UnionType $type, MappingContext $context): mixed + { + if ($json === null && $this->unionAllowsNull($type)) { + return null; + } + + $lastException = null; + + foreach ($type->getTypes() as $candidate) { + if ($this->isNullType($candidate) && $json !== null) { + continue; + } + + $errorCount = $context->getErrorCount(); + + try { + $converted = $this->convertValue($json, $candidate, $context); + } catch (MappingException $exception) { + $context->trimErrors($errorCount); + $lastException = $exception; + + continue; + } + + if ($context->getErrorCount() > $errorCount) { + $context->trimErrors($errorCount); + + $lastException = new TypeMismatchException( + $context->getPath(), + $this->describeType($candidate), + get_debug_type($json), + ); + + continue; + } + + return $converted; + } + + if ($lastException instanceof MappingException) { + throw $lastException; + } + + $exception = new TypeMismatchException( + $context->getPath(), + $this->describeUnionType($type), + get_debug_type($json), + ); + + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; + } + + return $json; + } + + /** + * Returns a string representation of the provided type. + */ + private function describeType(Type $type): string + { + if ($type instanceof BuiltinType) { + return $type->getTypeIdentifier()->value . ($type->isNullable() ? '|null' : ''); + } + + if ($type instanceof ObjectType) { + return $type->getClassName(); + } + + if ($type instanceof CollectionType) { + return 'array'; + } + + if ($this->isNullType($type)) { + return 'null'; + } + + if ($type instanceof UnionType) { + return $this->describeUnionType($type); + } + + return $type::class; + } + + /** + * Returns a textual representation of the union type. + */ + private function describeUnionType(UnionType $type): string + { + $parts = []; + + foreach ($type->getTypes() as $candidate) { + $parts[] = $this->describeType($candidate); + } + + return implode('|', $parts); + } + + private function unionAllowsNull(UnionType $type): bool + { + foreach ($type->getTypes() as $candidate) { + if ($this->isNullType($candidate)) { + return true; + } + } + + return false; + } + + private function isNullType(Type $type): bool + { + return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; + } + /** * Creates an instance of the given class name. * @@ -402,11 +559,13 @@ private function makeInstance(string $className, mixed ...$constructorArguments) */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { - return $this->hasPropertyAnnotation( - $className, - $propertyName, - ReplaceNullWithDefaultValue::class, - ); + $reflectionProperty = $this->getReflectionProperty($className, $propertyName); + + if (!($reflectionProperty instanceof ReflectionProperty)) { + return false; + } + + return $this->hasAttribute($reflectionProperty, ReplaceNullWithDefaultValue::class); } /** @@ -418,23 +577,31 @@ private function isReplaceNullWithDefaultValueAnnotation(string $className, stri */ private function buildReplacePropertyMap(string $className): array { - $map = []; + $reflectionClass = $this->getReflectionClass($className); - foreach ($this->extractClassAnnotations($className) as $annotation) { - if (!($annotation instanceof ReplaceProperty)) { - continue; - } + if (!($reflectionClass instanceof ReflectionClass)) { + return []; + } - if (!is_string($annotation->value)) { - continue; - } + $map = []; - $map[$annotation->replaces] = $annotation->value; + foreach ($reflectionClass->getAttributes(ReplaceProperty::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + /** @var ReplaceProperty $instance */ + $instance = $attribute->newInstance(); + $map[$instance->replaces] = $instance->value; } return $map; } + /** + * @param class-string $attributeClass + */ + private function hasAttribute(ReflectionProperty $property, string $attributeClass): bool + { + return $property->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF) !== []; + } + /** * Normalizes the property name using annotations and converters. * @@ -503,63 +670,6 @@ private function getReflectionClass(string $className): ?ReflectionClass return new ReflectionClass($className); } - /** - * Extracts possible property annotations. - * - * @param class-string $className - * - * @return Annotation[]|object[] - */ - private function extractPropertyAnnotations(string $className, string $propertyName): array - { - $reflectionProperty = $this->getReflectionProperty($className, $propertyName); - - if ($reflectionProperty instanceof ReflectionProperty) { - return (new AnnotationReader()) - ->getPropertyAnnotations($reflectionProperty); - } - - return []; - } - - /** - * Extracts possible class annotations. - * - * @param class-string $className - * - * @return Annotation[]|object[] - */ - private function extractClassAnnotations(string $className): array - { - $reflectionClass = $this->getReflectionClass($className); - - if ($reflectionClass instanceof ReflectionClass) { - return (new AnnotationReader()) - ->getClassAnnotations($reflectionClass); - } - - return []; - } - - /** - * Returns TRUE if the property has the given annotation. - * - * @param class-string $className - * @param class-string $annotationName - */ - private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool - { - $annotations = $this->extractPropertyAnnotations($className, $propertyName); - - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - return true; - } - } - - return false; - } - /** * Returns the default value of a property. * @@ -641,8 +751,14 @@ private function assertClassesExists(string $className, ?string $collectionClass /** * Sets a property value. */ - private function setProperty(object $entity, string $name, mixed $value): void + private function setProperty(object $entity, string $name, mixed $value, MappingContext $context): void { + $reflectionProperty = $this->getReflectionProperty($entity::class, $name); + + if ($reflectionProperty instanceof ReflectionProperty && $reflectionProperty->isReadOnly()) { + throw new ReadonlyPropertyException($context->getPath(), $name, $entity::class); + } + if (is_array($value)) { $methodName = 'set' . ucfirst($name); diff --git a/src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php b/src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php deleted file mode 100644 index 740c687..0000000 --- a/src/JsonMapper/Annotation/ReplaceNullWithDefaultValue.php +++ /dev/null @@ -1,30 +0,0 @@ -isStrictMode(), $context->shouldCollectErrors(), + (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false), ); } @@ -50,6 +52,14 @@ public function withErrorCollection(bool $collect): self return $clone; } + public function withEmptyStringAsNull(bool $enabled): self + { + $clone = clone $this; + $clone->emptyStringIsNull = $enabled; + + return $clone; + } + public function isStrictMode(): bool { return $this->strictMode; @@ -60,14 +70,20 @@ public function shouldCollectErrors(): bool return $this->collectErrors; } + public function shouldTreatEmptyStringAsNull(): bool + { + return $this->emptyStringIsNull; + } + /** * @return array */ public function toOptions(): array { return [ - MappingContext::OPTION_STRICT_MODE => $this->strictMode, - MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, ]; } } diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 8cf2959..510dd2d 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -13,13 +13,17 @@ use MagicSunday\JsonMapper\Exception\MappingException; +use function array_slice; +use function count; + /** * Represents the state shared while mapping JSON structures. */ final class MappingContext { - public const OPTION_STRICT_MODE = 'strict_mode'; - public const OPTION_COLLECT_ERRORS = 'collect_errors'; + public const OPTION_STRICT_MODE = 'strict_mode'; + public const OPTION_COLLECT_ERRORS = 'collect_errors'; + public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; /** * @var list @@ -161,4 +165,14 @@ public function getErrorRecords(): array { return $this->errorRecords; } + + public function getErrorCount(): int + { + return count($this->errorRecords); + } + + public function trimErrors(int $count): void + { + $this->errorRecords = array_slice($this->errorRecords, 0, $count); + } } diff --git a/src/JsonMapper/Exception/ReadonlyPropertyException.php b/src/JsonMapper/Exception/ReadonlyPropertyException.php new file mode 100644 index 0000000..48e5256 --- /dev/null +++ b/src/JsonMapper/Exception/ReadonlyPropertyException.php @@ -0,0 +1,28 @@ +extractor->getType($className, $propertyName); - if ($type instanceof UnionType) { - $type = $type->getTypes()[0]; - } - if ($type === null) { $type = $this->resolveFromReflection($className, $propertyName); } - $resolved = $type ?? $this->defaultType; + if ($type instanceof Type) { + $resolved = $this->normalizeType($type); + } else { + $resolved = $this->defaultType; + } $this->storeCachedType($className, $propertyName, $resolved); return $resolved; } + private function normalizeType(Type $type): Type + { + if ($type instanceof UnionType) { + return $this->normalizeUnionType($type); + } + + return $type; + } + /** * Returns a cached type if available. * @@ -154,8 +163,8 @@ private function resolveFromReflection(string $className, string $propertyName): } if ($reflectionType instanceof ReflectionUnionType) { + $types = []; $allowsNull = false; - $primary = null; foreach ($reflectionType->getTypes() as $innerType) { if (!$innerType instanceof ReflectionNamedType) { @@ -168,12 +177,24 @@ private function resolveFromReflection(string $className, string $propertyName): continue; } - $primary ??= $innerType; + $resolved = $this->createTypeFromNamedReflection($innerType); + + if ($resolved instanceof Type) { + $types[] = $resolved; + } } - if ($primary instanceof ReflectionNamedType) { - return $this->createTypeFromNamedReflection($primary, $allowsNull || $primary->allowsNull()); + if ($types === []) { + return $allowsNull ? Type::nullable($this->defaultType) : null; } + + $union = count($types) === 1 ? $types[0] : Type::union(...$types); + + if ($allowsNull) { + return Type::nullable($union); + } + + return $union; } return null; @@ -203,4 +224,37 @@ private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool return $resolved; } + + private function normalizeUnionType(UnionType $type): Type + { + $types = []; + $allowsNull = false; + + foreach ($type->getTypes() as $inner) { + if ($this->isNullType($inner)) { + $allowsNull = true; + + continue; + } + + $types[] = $this->normalizeType($inner); + } + + if ($types === []) { + return $allowsNull ? Type::nullable($this->defaultType) : $this->defaultType; + } + + $union = count($types) === 1 ? $types[0] : Type::union(...$types); + + if ($allowsNull) { + return Type::nullable($union); + } + + return $union; + } + + private function isNullType(Type $type): bool + { + return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; + } } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index cf0d1c5..74f65d7 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -19,6 +19,7 @@ use Traversable; use function assert; +use function filter_var; use function get_debug_type; use function is_array; use function is_bool; @@ -28,6 +29,12 @@ use function is_object; use function is_string; use function settype; +use function strtolower; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_FLOAT; +use const FILTER_VALIDATE_INT; /** * Converts scalar values to the requested builtin type. @@ -43,14 +50,79 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe { assert($type instanceof BuiltinType); - $this->guardCompatibility($value, $type, $context); + $normalized = $this->normalizeValue($value, $type); + + $this->guardCompatibility($normalized, $type, $context); + + if ($normalized === null) { + return null; + } - $converted = $value; + $converted = $normalized; settype($converted, $type->getTypeIdentifier()->value); return $converted; } + private function normalizeValue(mixed $value, BuiltinType $type): mixed + { + if ($value === null) { + return null; + } + + $identifier = $type->getTypeIdentifier()->value; + + if ($identifier === 'bool') { + if (is_string($value)) { + $normalized = strtolower(trim($value)); + + if ($normalized === '1' || $normalized === 'true') { + return true; + } + + if ($normalized === '0' || $normalized === 'false') { + return false; + } + } + + if (is_int($value)) { + if ($value === 0) { + return false; + } + + if ($value === 1) { + return true; + } + } + } + + if ($identifier === 'int' && is_string($value)) { + $filtered = filter_var(trim($value), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + if ($filtered !== null) { + return $filtered; + } + } + + if ($identifier === 'float' && is_string($value)) { + $filtered = filter_var(trim($value), FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + + if ($filtered !== null) { + return $filtered; + } + } + + if ($identifier === 'int' && is_float($value)) { + return (int) $value; + } + + if ($identifier === 'float' && is_int($value)) { + return (float) $value; + } + + return $value; + } + private function guardCompatibility(mixed $value, BuiltinType $type, MappingContext $context): void { $identifier = $type->getTypeIdentifier(); diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php new file mode 100644 index 0000000..eaf08be --- /dev/null +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -0,0 +1,87 @@ +getClassName(); + + if ($className === '') { + return false; + } + + return is_a($className, DateTimeImmutable::class, true) || is_a($className, DateInterval::class, true); + } + + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + if (!($type instanceof ObjectType)) { + return $value; + } + + $className = $type->getClassName(); + + if ($value === null) { + if ($type->isNullable()) { + return null; + } + + throw new TypeMismatchException($context->getPath(), $className, 'null'); + } + + if (!is_string($value) && !is_int($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + if (is_a($className, DateInterval::class, true)) { + if (!is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + return new $className($value); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + } + + $formatted = is_int($value) ? '@' . $value : $value; + + try { + return new $className($formatted); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + } +} diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php new file mode 100644 index 0000000..97fd179 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -0,0 +1,80 @@ +getClassName(); + + if ($className === '') { + return false; + } + + if (!enum_exists($className)) { + return false; + } + + return is_a($className, BackedEnum::class, true); + } + + public function convert(mixed $value, Type $type, MappingContext $context): mixed + { + if (!($type instanceof ObjectType)) { + return $value; + } + + $className = $type->getClassName(); + + if ($value === null) { + if ($type->isNullable()) { + return null; + } + + throw new TypeMismatchException($context->getPath(), $className, 'null'); + } + + if (!is_int($value) && !is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + /** @var BackedEnum $enum */ + $enum = $className::from($value); + } catch (ValueError) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + return $enum; + } +} diff --git a/tests/Annotation/ReplacePropertyTest.php b/tests/Attribute/ReplacePropertyTest.php similarity index 97% rename from tests/Annotation/ReplacePropertyTest.php rename to tests/Attribute/ReplacePropertyTest.php index ae8be85..73c9bbe 100644 --- a/tests/Annotation/ReplacePropertyTest.php +++ b/tests/Attribute/ReplacePropertyTest.php @@ -9,7 +9,7 @@ declare(strict_types=1); -namespace MagicSunday\Test\Annotation; +namespace MagicSunday\Test\Attribute; use MagicSunday\Test\Classes\ReplacePropertyTestClass; use MagicSunday\Test\TestCase; diff --git a/tests/Classes/DateTimeHolder.php b/tests/Classes/DateTimeHolder.php new file mode 100644 index 0000000..cb42c41 --- /dev/null +++ b/tests/Classes/DateTimeHolder.php @@ -0,0 +1,22 @@ + - * - * @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue */ + #[ReplaceNullWithDefaultValue] public array $array = []; } diff --git a/tests/Classes/NullableStringHolder.php b/tests/Classes/NullableStringHolder.php new file mode 100644 index 0000000..db4f672 --- /dev/null +++ b/tests/Classes/NullableStringHolder.php @@ -0,0 +1,17 @@ + * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ - * - * @ReplaceProperty("type", replaces="ftype") - * @ReplaceProperty("name", replaces="super-cryptic-name") */ +#[ReplaceProperty('type', replaces: 'ftype')] +#[ReplaceProperty('name', replaces: 'super-cryptic-name')] class ReplacePropertyTestClass { /** diff --git a/tests/Classes/ScalarHolder.php b/tests/Classes/ScalarHolder.php new file mode 100644 index 0000000..d49afc9 --- /dev/null +++ b/tests/Classes/ScalarHolder.php @@ -0,0 +1,21 @@ +isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); } #[Test] @@ -48,22 +49,34 @@ public function itSupportsTogglingErrorCollection(): void self::assertFalse($configuration->shouldCollectErrors()); } + #[Test] + public function itSupportsEmptyStringConfiguration(): void + { + $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); + + self::assertTrue($configuration->shouldTreatEmptyStringAsNull()); + self::assertTrue($configuration->withEmptyStringAsNull(true)->shouldTreatEmptyStringAsNull()); + } + #[Test] public function itDerivesFromContext(): void { $context = new MappingContext([], [ MappingContext::OPTION_STRICT_MODE => true, MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ]); $configuration = MappingConfiguration::fromContext($context); self::assertTrue($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); self::assertSame( [ MappingContext::OPTION_STRICT_MODE => true, MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ], $configuration->toOptions(), ); diff --git a/tests/JsonMapper/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php index 793138d..e53132c 100644 --- a/tests/JsonMapper/JsonMapperErrorHandlingTest.php +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -14,10 +14,12 @@ use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Exception\CollectionMappingException; use MagicSunday\JsonMapper\Exception\MissingPropertyException; +use MagicSunday\JsonMapper\Exception\ReadonlyPropertyException; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\Person; +use MagicSunday\Test\Classes\ReadonlyEntity; use MagicSunday\Test\Classes\Simple; use MagicSunday\Test\TestCase; use PHPUnit\Framework\Attributes\Test; @@ -175,6 +177,25 @@ public function itCollectsNestedErrorsAcrossObjectGraphs(): void self::assertInstanceOf(UnknownPropertyException::class, $errorsByPath['$.simple.unknown']->getException()); } + #[Test] + public function itReportsReadonlyPropertyViolations(): void + { + $result = $this->getJsonMapper() + ->mapWithReport([ + 'id' => 'changed', + ], ReadonlyEntity::class); + + $entity = $result->getValue(); + + self::assertInstanceOf(ReadonlyEntity::class, $entity); + self::assertSame('initial', $entity->id); + + $errors = $result->getReport()->getErrors(); + self::assertCount(1, $errors); + self::assertInstanceOf(ReadonlyPropertyException::class, $errors[0]->getException()); + self::assertSame('Readonly property ' . ReadonlyEntity::class . '::id cannot be written at $.id.', $errors[0]->getMessage()); + } + #[Test] public function itThrowsOnInvalidNestedCollectionEntriesInStrictMode(): void { diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index e2d8f80..b22f076 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -11,6 +11,8 @@ namespace MagicSunday\Test; +use DateInterval; +use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\ClassMap\CollectionSource; use MagicSunday\Test\Classes\ClassMap\CollectionTarget; @@ -18,14 +20,20 @@ use MagicSunday\Test\Classes\ClassMap\TargetItem; use MagicSunday\Test\Classes\Collection; use MagicSunday\Test\Classes\CustomConstructor; +use MagicSunday\Test\Classes\DateTimeHolder; +use MagicSunday\Test\Classes\EnumHolder; use MagicSunday\Test\Classes\Initialized; use MagicSunday\Test\Classes\MapPlainArrayKeyValueClass; use MagicSunday\Test\Classes\MultidimensionalArray; +use MagicSunday\Test\Classes\NullableStringHolder; use MagicSunday\Test\Classes\Person; use MagicSunday\Test\Classes\PlainArrayClass; +use MagicSunday\Test\Classes\ScalarHolder; use MagicSunday\Test\Classes\Simple; +use MagicSunday\Test\Classes\UnionHolder; use MagicSunday\Test\Classes\VariadicSetterClass; use MagicSunday\Test\Classes\VipPerson; +use MagicSunday\Test\Fixtures\Enum\SampleStatus; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use stdClass; @@ -528,11 +536,11 @@ public function mapInitialized(): void } /** - * Tests mapping of default values using @MagicSunday\JsonMapper\Annotation\ReplaceNullWithDefaultValue - * annotation in case JSON contains NULL. + * Tests mapping of default values using #[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] + * when the JSON payload contains null values. */ #[Test] - public function mapNullToDefaultValueUsingAnnotation(): void + public function mapNullToDefaultValueUsingAttribute(): void { $result = $this->getJsonMapper() ->map( @@ -758,4 +766,107 @@ public function mappingCollectionElementsUsingClassMap(): void self::assertInstanceOf(CollectionTarget::class, $result); self::assertContainsOnlyInstancesOf(TargetItem::class, $result); } + + #[Test] + public function mapBackedEnumFromString(): void + { + $result = $this->getJsonMapper() + ->map(['status' => 'active'], EnumHolder::class); + + self::assertInstanceOf(EnumHolder::class, $result); + self::assertSame(SampleStatus::Active, $result->status); + } + + #[Test] + public function mapUnionTypeWithNumericString(): void + { + $result = $this->getJsonMapper() + ->map([ + 'value' => '42', + 'fallback' => 'hello', + ], UnionHolder::class); + + self::assertInstanceOf(UnionHolder::class, $result); + self::assertSame(42, $result->value); + self::assertSame('hello', $result->fallback); + } + + #[Test] + public function mapUnionTypeWithTextualValue(): void + { + $result = $this->getJsonMapper() + ->map([ + 'value' => 'oops', + 'fallback' => 99, + ], UnionHolder::class); + + self::assertInstanceOf(UnionHolder::class, $result); + self::assertSame('oops', $result->value); + self::assertSame(99, $result->fallback); + } + + #[Test] + public function mapDateTimeAndIntervalValues(): void + { + $result = $this->getJsonMapper() + ->map([ + 'createdAt' => '2024-04-01T12:00:00+00:00', + 'timeout' => 'PT15M', + ], DateTimeHolder::class); + + self::assertInstanceOf(DateTimeHolder::class, $result); + self::assertSame('2024-04-01T12:00:00+00:00', $result->createdAt->format('c')); + self::assertInstanceOf(DateInterval::class, $result->timeout); + self::assertSame(15, $result->timeout->i); + } + + #[Test] + public function mapScalarShorthandValues(): void + { + $result = $this->getJsonMapper() + ->map([ + 'intValue' => '42', + 'floatValue' => '3.14', + 'boolValue' => '1', + ], ScalarHolder::class); + + self::assertInstanceOf(ScalarHolder::class, $result); + self::assertSame(42, $result->intValue); + self::assertSame(3.14, $result->floatValue); + self::assertTrue($result->boolValue); + } + + #[Test] + public function mapScalarZeroStringToFalse(): void + { + $result = $this->getJsonMapper() + ->map([ + 'intValue' => '0', + 'floatValue' => '0', + 'boolValue' => '0', + ], ScalarHolder::class); + + self::assertInstanceOf(ScalarHolder::class, $result); + self::assertSame(0, $result->intValue); + self::assertSame(0.0, $result->floatValue); + self::assertFalse($result->boolValue); + } + + #[Test] + public function mapEmptyStringToNullWhenEnabled(): void + { + $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); + + $result = $this->getJsonMapper() + ->map( + ['value' => ''], + NullableStringHolder::class, + null, + null, + $configuration, + ); + + self::assertInstanceOf(NullableStringHolder::class, $result); + self::assertNull($result->value); + } } From 8fb15c74058d9248261e3b44943d0871f3766242 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 23:20:47 +0100 Subject: [PATCH 16/46] Support generic collection mapping --- src/JsonMapper.php | 78 ++++++++++++---- .../CollectionDocBlockTypeResolver.php | 93 +++++++++++++++++++ .../Collection/CollectionFactory.php | 2 +- .../Collection/CollectionFactoryInterface.php | 43 +++++++++ .../CollectionValueConversionStrategy.php | 4 +- tests/Classes/BaseCollection.php | 25 +++++ tests/Classes/Collection.php | 4 +- .../MappingConfigurationTest.php | 8 +- tests/JsonMapperTest.php | 23 +++++ 9 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php create mode 100644 src/JsonMapper/Collection/CollectionFactoryInterface.php create mode 100644 tests/Classes/BaseCollection.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 675b096..88d83cc 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -15,7 +15,9 @@ use InvalidArgumentException; use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; use MagicSunday\JsonMapper\Attribute\ReplaceProperty; +use MagicSunday\JsonMapper\Collection\CollectionDocBlockTypeResolver; use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; @@ -52,6 +54,7 @@ use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\TemplateType; use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; use Traversable; @@ -93,7 +96,9 @@ class JsonMapper private ValueConverter $valueConverter; - private CollectionFactory $collectionFactory; + private CollectionFactoryInterface $collectionFactory; + + private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; private CustomTypeRegistry $customTypeRegistry; @@ -110,11 +115,12 @@ public function __construct( array $classMap = [], ?CacheItemPoolInterface $typeCache = null, ) { - $this->typeResolver = new TypeResolver($extractor, $typeCache); - $this->classResolver = new ClassResolver($classMap); - $this->customTypeRegistry = new CustomTypeRegistry(); - $this->valueConverter = new ValueConverter(); - $this->collectionFactory = new CollectionFactory( + $this->typeResolver = new TypeResolver($extractor, $typeCache); + $this->classResolver = new ClassResolver($classMap); + $this->customTypeRegistry = new CustomTypeRegistry(); + $this->collectionDocBlockTypeResolver = new CollectionDocBlockTypeResolver(); + $this->valueConverter = new ValueConverter(); + $this->collectionFactory = new CollectionFactory( $this->valueConverter, $this->classResolver, function (string $className, ?array $arguments): object { @@ -198,20 +204,60 @@ public function map( } } - if ($className === null) { - return $json; - } - - /** @var class-string $resolvedClassName */ - $resolvedClassName = $this->classResolver->resolve($className, $json, $context); + $resolvedClassName = $className === null + ? null + : $this->classResolver->resolve($className, $json, $context); - /** @var class-string|null $resolvedCollectionClassName */ $resolvedCollectionClassName = $collectionClassName === null ? null : $this->classResolver->resolve($collectionClassName, $json, $context); $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); + /** @var Type|null $collectionValueType */ + $collectionValueType = null; + + if ($resolvedCollectionClassName !== null) { + if ($resolvedClassName !== null) { + $collectionValueType = new ObjectType($resolvedClassName); + } else { + $docBlockCollectionType = $this->collectionDocBlockTypeResolver->resolve($resolvedCollectionClassName); + + if (!$docBlockCollectionType instanceof CollectionType) { + throw new InvalidArgumentException(sprintf( + 'Unable to resolve the element type for collection [%s]. Define an "@extends" annotation such as "@extends %s".', + $resolvedCollectionClassName, + $resolvedCollectionClassName, + )); + } + + $collectionValueType = $docBlockCollectionType->getCollectionValueType(); + + if ($collectionValueType instanceof TemplateType) { + throw new InvalidArgumentException(sprintf( + 'Unable to resolve the element type for collection [%s]. Please provide a concrete class in the "@extends" annotation.', + $resolvedCollectionClassName, + )); + } + } + } + + $isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType !== null; + + if ($isGenericCollectionMapping) { + if ($resolvedCollectionClassName === null) { + throw new InvalidArgumentException('A collection class name must be provided when mapping without an element class.'); + } + + $collection = $this->collectionFactory->mapIterable($json, $collectionValueType, $context); + + return $this->makeInstance($resolvedCollectionClassName, $collection); + } + + if ($resolvedClassName === null) { + return $json; + } + if (!is_array($json) && !is_object($json)) { return $this->makeInstance($resolvedClassName); } @@ -220,7 +266,7 @@ public function map( ($resolvedCollectionClassName !== null) && $this->isIterableWithArraysOrObjects($json) ) { - $collection = $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); + $collection = $this->collectionFactory->mapIterable($json, $collectionValueType ?? new ObjectType($resolvedClassName), $context); return $this->makeInstance($resolvedCollectionClassName, $collection); } @@ -731,9 +777,9 @@ private function isIterableWithArraysOrObjects(mixed $json): bool /** * Assert that the given classes exist. */ - private function assertClassesExists(string $className, ?string $collectionClassName = null): void + private function assertClassesExists(?string $className, ?string $collectionClassName = null): void { - if (!class_exists($className)) { + if ($className !== null && !class_exists($className)) { throw new InvalidArgumentException(sprintf('Class [%s] does not exist', $className)); } diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php new file mode 100644 index 0000000..63a08e2 --- /dev/null +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -0,0 +1,93 @@ +docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); + $this->contextFactory = $contextFactory ?? new ContextFactory(); + $this->phpDocTypeHelper = $phpDocTypeHelper ?? new PhpDocTypeHelper(); + } + + /** + * Attempts to resolve a {@see CollectionType} from the collection class PHPDoc. + * + * @param class-string $collectionClassName + */ + public function resolve(string $collectionClassName): ?CollectionType + { + $reflectionClass = new ReflectionClass($collectionClassName); + $docComment = $reflectionClass->getDocComment(); + + if ($docComment === false) { + return null; + } + + $context = $this->contextFactory->createFromReflector($reflectionClass); + $docBlock = $this->docBlockFactory->create($docComment, $context); + + foreach (['extends', 'implements'] as $tagName) { + foreach ($docBlock->getTagsByName($tagName) as $tag) { + if (!$tag instanceof TagWithType) { + continue; + } + + $type = $tag->getType(); + + if (!$type instanceof DocType) { + continue; + } + + $resolved = $this->phpDocTypeHelper->getType($type); + + if ($resolved instanceof CollectionType) { + return $resolved; + } + } + } + + return null; + } +} diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index c37b69f..014feaa 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -32,7 +32,7 @@ /** * Creates collections and hydrates wrapping collection classes. */ -final readonly class CollectionFactory +final readonly class CollectionFactory implements CollectionFactoryInterface { /** * @param Closure(class-string, array|null):object $instantiator diff --git a/src/JsonMapper/Collection/CollectionFactoryInterface.php b/src/JsonMapper/Collection/CollectionFactoryInterface.php new file mode 100644 index 0000000..bfc3d6b --- /dev/null +++ b/src/JsonMapper/Collection/CollectionFactoryInterface.php @@ -0,0 +1,43 @@ +|null + */ + public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array; + + /** + * Builds a collection based on the specified collection type description. + * + * @param CollectionType $type The collection type metadata extracted from PHPStan/Psalm annotations. + * + * @return array|object|null + */ + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed; +} diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index 6fdff27..5227c83 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -11,7 +11,7 @@ namespace MagicSunday\JsonMapper\Value\Strategy; -use MagicSunday\JsonMapper\Collection\CollectionFactory; +use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; use MagicSunday\JsonMapper\Context\MappingContext; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -24,7 +24,7 @@ final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface { public function __construct( - private CollectionFactory $collectionFactory, + private CollectionFactoryInterface $collectionFactory, ) { } diff --git a/tests/Classes/BaseCollection.php b/tests/Classes/BaseCollection.php new file mode 100644 index 0000000..510659d --- /dev/null +++ b/tests/Classes/BaseCollection.php @@ -0,0 +1,25 @@ + + * @license https://opensource.org/licenses/MIT + * @link https://github.com/magicsunday/jsonmapper/ + * + * @extends Collection + */ +final class BaseCollection extends Collection +{ +} diff --git a/tests/Classes/Collection.php b/tests/Classes/Collection.php index 0881d65..b1fe2f3 100644 --- a/tests/Classes/Collection.php +++ b/tests/Classes/Collection.php @@ -24,7 +24,9 @@ * @template TKey of array-key * @template TValue * - * @implements ArrayAccess + * @extends ArrayObject + * + * @implements ArrayAccess */ class Collection extends ArrayObject implements ArrayAccess { diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php index b398c63..943af84 100644 --- a/tests/JsonMapper/Configuration/MappingConfigurationTest.php +++ b/tests/JsonMapper/Configuration/MappingConfigurationTest.php @@ -62,8 +62,8 @@ public function itSupportsEmptyStringConfiguration(): void public function itDerivesFromContext(): void { $context = new MappingContext([], [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ]); @@ -74,8 +74,8 @@ public function itDerivesFromContext(): void self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); self::assertSame( [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, ], $configuration->toOptions(), diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index b22f076..27b4294 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -14,6 +14,7 @@ use DateInterval; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\Test\Classes\Base; +use MagicSunday\Test\Classes\BaseCollection; use MagicSunday\Test\Classes\ClassMap\CollectionSource; use MagicSunday\Test\Classes\ClassMap\CollectionTarget; use MagicSunday\Test\Classes\ClassMap\SourceItem; @@ -89,6 +90,28 @@ public function mapArrayOrCollection(string $jsonString): void self::assertSame('Item 2', $result[1]->name); } + /** + * Tests mapping a collection using a generic @extends annotation. + */ + #[Test] + public function mapCollectionUsingDocBlockExtends(): void + { + $result = $this->getJsonMapper() + ->map( + $this->getJsonAsArray(Provider\DataProvider::mapCollectionJson()), + null, + BaseCollection::class + ); + + self::assertInstanceOf(BaseCollection::class, $result); + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(Base::class, $result); + self::assertInstanceOf(Base::class, $result[0]); + self::assertSame('Item 1', $result[0]->name); + self::assertInstanceOf(Base::class, $result[1]); + self::assertSame('Item 2', $result[1]->name); + } + /** * Tests mapping an array or collection of objects. */ From 3c0d1d16a02f00528eab315c2d4b1d11656073db Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 12 Nov 2025 23:32:58 +0100 Subject: [PATCH 17/46] Add extensible type handler support --- README.md | 31 +++++---- src/JsonMapper.php | 18 ++++- src/JsonMapper/Value/ClosureTypeHandler.php | 67 +++++++++++++++++++ src/JsonMapper/Value/CustomTypeRegistry.php | 47 +++++++------ .../CustomTypeValueConversionStrategy.php | 9 +-- src/JsonMapper/Value/TypeHandlerInterface.php | 40 +++++++++++ .../Value/CustomTypeRegistryTest.php | 43 +++++++++++- tests/JsonMapperTest.php | 43 ++++++------ 8 files changed, 233 insertions(+), 65 deletions(-) create mode 100644 src/JsonMapper/Value/ClosureTypeHandler.php create mode 100644 src/JsonMapper/Value/TypeHandlerInterface.php diff --git a/README.md b/README.md index 6e53d9e..ad81123 100644 --- a/README.md +++ b/README.md @@ -136,24 +136,31 @@ $mapper = new \MagicSunday\JsonMapper( To handle custom or special types of objects, add them to the mapper. For instance to perform special treatment if an object of type Bar should be mapped: + +You may alternatively implement `\MagicSunday\JsonMapper\Value\TypeHandlerInterface` to package reusable handlers. + ```php -$mapper->addType( - Bar::class, - /** @var mixed $value JSON data */ - static function ($value): ?Bar { - return $value ? new Bar($value['name']) : null; - } +$mapper->addTypeHandler( + new \MagicSunday\JsonMapper\Value\ClosureTypeHandler( + Bar::class, + /** @var mixed $value JSON data */ + static function ($value): ?Bar { + return $value ? new Bar($value['name']) : null; + }, + ), ); ``` or add a handler to map DateTime values: ```php -$mapper->addType( - \DateTime::class, - /** @var mixed $value JSON data */ - static function ($value): ?\DateTime { - return $value ? new \DateTime($value) : null; - } +$mapper->addTypeHandler( + new \MagicSunday\JsonMapper\Value\ClosureTypeHandler( + \DateTime::class, + /** @var mixed $value JSON data */ + static function ($value): ?\DateTime { + return $value ? new \DateTime($value) : null; + }, + ), ); ``` diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 88d83cc..abb9997 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -30,6 +30,7 @@ use MagicSunday\JsonMapper\Report\MappingResult; use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Type\TypeResolver; +use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\JsonMapper\Value\CustomTypeRegistry; use MagicSunday\JsonMapper\Value\Strategy\BuiltinValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\CollectionValueConversionStrategy; @@ -39,6 +40,7 @@ use MagicSunday\JsonMapper\Value\Strategy\NullValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\ObjectValueConversionStrategy; use MagicSunday\JsonMapper\Value\Strategy\PassthroughValueConversionStrategy; +use MagicSunday\JsonMapper\Value\TypeHandlerInterface; use MagicSunday\JsonMapper\Value\ValueConverter; use Psr\Cache\CacheItemPoolInterface; use ReflectionAttribute; @@ -152,11 +154,23 @@ function (mixed $value, string $resolvedClass, MappingContext $context): mixed { } /** - * Add a custom type. + * Registers a custom type handler. + */ + public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper + { + $this->customTypeRegistry->registerHandler($handler); + + return $this; + } + + /** + * Registers a custom type using a closure-based handler. + * + * @deprecated Use addTypeHandler() with a TypeHandlerInterface implementation instead. */ public function addType(string $type, Closure $closure): JsonMapper { - $this->customTypeRegistry->register($type, $closure); + $this->customTypeRegistry->registerHandler(new ClosureTypeHandler($type, $closure)); return $this; } diff --git a/src/JsonMapper/Value/ClosureTypeHandler.php b/src/JsonMapper/Value/ClosureTypeHandler.php new file mode 100644 index 0000000..741026e --- /dev/null +++ b/src/JsonMapper/Value/ClosureTypeHandler.php @@ -0,0 +1,67 @@ +converter = $this->normalizeConverter($converter); + } + + public function supports(Type $type, mixed $value): bool + { + if (!$type instanceof ObjectType) { + return false; + } + + return $type->getClassName() === $this->className; + } + + public function convert(Type $type, mixed $value, MappingContext $context): mixed + { + if (!$this->supports($type, $value)) { + throw new LogicException(sprintf('Handler does not support type %s.', $type::class)); + } + + return ($this->converter)($value, $context); + } + + /** + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + */ + private function normalizeConverter(callable $converter): Closure + { + $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); + $reflection = new ReflectionFunction($closure); + + if ($reflection->getNumberOfParameters() >= 2) { + return $closure; + } + + return static fn (mixed $value, MappingContext $context): mixed => $closure($value); + } +} diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index 84a6174..7b99dd8 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -11,21 +11,21 @@ namespace MagicSunday\JsonMapper\Value; -use Closure; +use LogicException; use MagicSunday\JsonMapper\Context\MappingContext; -use ReflectionFunction; +use Symfony\Component\TypeInfo\Type; -use function array_key_exists; +use function sprintf; /** - * Stores custom conversion handlers keyed by class name. + * Stores custom conversion handlers. */ final class CustomTypeRegistry { /** - * @var array + * @var list */ - private array $converters = []; + private array $handlers = []; /** * Registers the converter for the provided class name. @@ -34,37 +34,42 @@ final class CustomTypeRegistry */ public function register(string $className, callable $converter): void { - $this->converters[$className] = $this->normalizeConverter($converter); + $this->registerHandler(new ClosureTypeHandler($className, $converter)); } /** - * Returns TRUE if a converter for the class exists. + * Registers a custom type handler. */ - public function has(string $className): bool + public function registerHandler(TypeHandlerInterface $handler): void { - return array_key_exists($className, $this->converters); + $this->handlers[] = $handler; } /** - * Executes the converter for the class. + * Returns TRUE if a handler for the type exists. */ - public function convert(string $className, mixed $value, MappingContext $context): mixed + public function supports(Type $type, mixed $value): bool { - return $this->converters[$className]($value, $context); + foreach ($this->handlers as $handler) { + if ($handler->supports($type, $value)) { + return true; + } + } + + return false; } /** - * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + * Executes the converter for the class. */ - private function normalizeConverter(callable $converter): Closure + public function convert(Type $type, mixed $value, MappingContext $context): mixed { - $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); - $reflection = new ReflectionFunction($closure); - - if ($reflection->getNumberOfParameters() >= 2) { - return $closure; + foreach ($this->handlers as $handler) { + if ($handler->supports($type, $value)) { + return $handler->convert($type, $value, $context); + } } - return static fn (mixed $value, MappingContext $context): mixed => $closure($value); + throw new LogicException(sprintf('No custom type handler registered for %s.', $type::class)); } } diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php index 5a9c5fe..c408a85 100644 --- a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -14,9 +14,6 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Value\CustomTypeRegistry; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; - -use function assert; /** * Handles conversion of registered custom types. @@ -30,13 +27,11 @@ public function __construct( public function supports(mixed $value, Type $type, MappingContext $context): bool { - return ($type instanceof ObjectType) && $this->registry->has($type->getClassName()); + return $this->registry->supports($type, $value); } public function convert(mixed $value, Type $type, MappingContext $context): mixed { - assert($type instanceof ObjectType); - - return $this->registry->convert($type->getClassName(), $value, $context); + return $this->registry->convert($type, $value, $context); } } diff --git a/src/JsonMapper/Value/TypeHandlerInterface.php b/src/JsonMapper/Value/TypeHandlerInterface.php new file mode 100644 index 0000000..a3b0230 --- /dev/null +++ b/src/JsonMapper/Value/TypeHandlerInterface.php @@ -0,0 +1,40 @@ +register('Foo', static fn (mixed $value): array => (array) $value); $context = new MappingContext([]); + $type = new ObjectType('Foo'); - self::assertTrue($registry->has('Foo')); - self::assertSame(['bar' => 'baz'], $registry->convert('Foo', ['bar' => 'baz'], $context)); + self::assertTrue($registry->supports($type, ['bar' => 'baz'])); + self::assertSame(['bar' => 'baz'], $registry->convert($type, ['bar' => 'baz'], $context)); } #[Test] @@ -44,8 +50,39 @@ public function itPassesContextToConverters(): void }); $context = new MappingContext([]); - $registry->convert('Foo', ['payload'], $context); + $type = new ObjectType('Foo'); + $registry->convert($type, ['payload'], $context); self::assertSame(['called'], $context->getErrors()); } + + #[Test] + public function itSupportsCustomHandlers(): void + { + $registry = new CustomTypeRegistry(); + $registry->registerHandler(new class implements TypeHandlerInterface { + public function supports(\Symfony\Component\TypeInfo\Type $type, mixed $value): bool + { + return $type instanceof ObjectType && $type->getClassName() === 'Foo'; + } + + public function convert(\Symfony\Component\TypeInfo\Type $type, mixed $value, MappingContext $context): mixed + { + if (!is_string($value)) { + throw new InvalidArgumentException('Expected string value.'); + } + + $context->addError('converted'); + + return 'handled-' . $value; + } + }); + + $context = new MappingContext([]); + $type = new ObjectType('Foo'); + + self::assertTrue($registry->supports($type, 'value')); + self::assertSame('handled-value', $registry->convert($type, 'value', $context)); + self::assertSame(['converted'], $context->getErrors()); + } } diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index 27b4294..cdaa998 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -13,6 +13,7 @@ use DateInterval; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\BaseCollection; use MagicSunday\Test\Classes\ClassMap\CollectionSource; @@ -250,27 +251,29 @@ public static function mapCustomTypeJsonDataProvider(): array public function mapCustomType(string $jsonString): void { $result = $this->getJsonMapper() - ->addType( - CustomConstructor::class, - static function (mixed $value): ?CustomConstructor { - if ( - is_array($value) - && isset($value['name']) - && is_string($value['name']) - ) { - return new CustomConstructor($value['name']); - } - - if ( - ($value instanceof stdClass) - && property_exists($value, 'name') - && is_string($value->name) - ) { - return new CustomConstructor($value->name); - } + ->addTypeHandler( + new ClosureTypeHandler( + CustomConstructor::class, + static function (mixed $value): ?CustomConstructor { + if ( + is_array($value) + && isset($value['name']) + && is_string($value['name']) + ) { + return new CustomConstructor($value['name']); + } - return null; - } + if ( + ($value instanceof stdClass) + && property_exists($value, 'name') + && is_string($value->name) + ) { + return new CustomConstructor($value->name); + } + + return null; + }, + ), ) ->map( $this->getJsonAsArray($jsonString), From 58337363afc648f1790b1922f621a44da226e2f9 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 00:03:29 +0100 Subject: [PATCH 18/46] Refine object conversion guards and CPD command --- composer.json | 2 +- src/JsonMapper.php | 38 +++- .../Collection/CollectionFactory.php | 4 + .../Configuration/JsonMapperConfig.php | 167 ++++++++++++++++++ .../Configuration/MappingConfiguration.php | 75 +++++++- src/JsonMapper/Context/MappingContext.php | 39 +++- .../DateTimeValueConversionStrategy.php | 87 ++++----- .../Strategy/EnumValueConversionStrategy.php | 56 +++--- .../ObjectTypeConversionGuardTrait.php | 77 ++++++++ .../ObjectValueConversionStrategy.php | 10 +- .../Configuration/JsonMapperConfigTest.php | 79 +++++++++ .../MappingConfigurationTest.php | 45 ++++- .../JsonMapper/Context/MappingContextTest.php | 16 ++ tests/JsonMapperTest.php | 88 +++++++++ tests/TestCase.php | 6 +- 15 files changed, 691 insertions(+), 98 deletions(-) create mode 100644 src/JsonMapper/Configuration/JsonMapperConfig.php create mode 100644 src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php create mode 100644 tests/JsonMapper/Configuration/JsonMapperConfigTest.php diff --git a/composer.json b/composer.json index 9d700ef..5d02e77 100644 --- a/composer.json +++ b/composer.json @@ -76,7 +76,7 @@ "@ci:rector --dry-run" ], "ci:test:php:cpd": [ - "npx -y jscpd@latest --config .jscpd.json" + "npx jscpd --config .jscpd.json" ], "ci:test:php:unit": [ "phpunit --configuration phpunit.xml" diff --git a/src/JsonMapper.php b/src/JsonMapper.php index abb9997..f2b08aa 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -18,6 +18,7 @@ use MagicSunday\JsonMapper\Collection\CollectionDocBlockTypeResolver; use MagicSunday\JsonMapper\Collection\CollectionFactory; use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; @@ -104,6 +105,8 @@ class JsonMapper private CustomTypeRegistry $customTypeRegistry; + private JsonMapperConfig $config; + /** * @param array $classMap * @param CacheItemPoolInterface|null $typeCache @@ -116,7 +119,9 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, + ?JsonMapperConfig $config = null, ) { + $this->config = $config ?? new JsonMapperConfig(); $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); $this->customTypeRegistry = new CustomTypeRegistry(); @@ -208,8 +213,8 @@ public function map( ?MappingConfiguration $configuration = null, ): mixed { if ($context === null) { - $configuration ??= MappingConfiguration::lenient(); - $context = new MappingContext($json, $configuration->toOptions()); + $configuration = $configuration ?? $this->createDefaultConfiguration(); + $context = new MappingContext($json, $configuration->toOptions()); } else { if ($configuration === null) { $configuration = MappingConfiguration::fromContext($context); @@ -317,6 +322,10 @@ public function map( } if (!in_array($normalizedProperty, $properties, true)) { + if ($configuration->shouldIgnoreUnknownProperties()) { + return; + } + $this->handleMappingException( new UnknownPropertyException($propertyContext->getPath(), $normalizedProperty, $resolvedClassName), $propertyContext, @@ -385,13 +394,36 @@ public function mapWithReport( ?string $collectionClassName = null, ?MappingConfiguration $configuration = null, ): MappingResult { - $configuration = ($configuration ?? MappingConfiguration::lenient())->withErrorCollection(true); + $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); $context = new MappingContext($json, $configuration->toOptions()); $value = $this->map($json, $className, $collectionClassName, $context, $configuration); return new MappingResult($value, new MappingReport($context->getErrorRecords())); } + private function createDefaultConfiguration(): MappingConfiguration + { + $configuration = $this->config->isStrictMode() + ? MappingConfiguration::strict() + : MappingConfiguration::lenient(); + + if ($this->config->shouldIgnoreUnknownProperties()) { + $configuration = $configuration->withIgnoreUnknownProperties(true); + } + + if ($this->config->shouldTreatNullAsEmptyCollection()) { + $configuration = $configuration->withTreatNullAsEmptyCollection(true); + } + + $configuration = $configuration->withDefaultDateFormat($this->config->getDefaultDateFormat()); + + if ($this->config->shouldAllowScalarToObjectCasting()) { + $configuration = $configuration->withScalarToObjectCasting(true); + } + + return $configuration; + } + /** * @param class-string $className * @param array $declaredProperties diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index 014feaa..a5ccfd7 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -52,6 +52,10 @@ public function __construct( public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array { if ($json === null) { + if ($context->shouldTreatNullAsEmptyCollection()) { + return []; + } + return null; } diff --git a/src/JsonMapper/Configuration/JsonMapperConfig.php b/src/JsonMapper/Configuration/JsonMapperConfig.php new file mode 100644 index 0000000..807ae15 --- /dev/null +++ b/src/JsonMapper/Configuration/JsonMapperConfig.php @@ -0,0 +1,167 @@ + $data Configuration values indexed by property name + */ + public static function fromArray(array $data): self + { + $defaultDateFormat = $data['defaultDateFormat'] ?? DateTimeInterface::ATOM; + + if (!is_string($defaultDateFormat)) { + $defaultDateFormat = DateTimeInterface::ATOM; + } + + return new self( + (bool) ($data['strictMode'] ?? false), + (bool) ($data['ignoreUnknownProperties'] ?? false), + (bool) ($data['treatNullAsEmptyCollection'] ?? false), + $defaultDateFormat, + (bool) ($data['allowScalarToObjectCasting'] ?? false), + ); + } + + /** + * Serializes the configuration into an array representation. + * + * @return array + */ + public function toArray(): array + { + return [ + 'strictMode' => $this->strictMode, + 'ignoreUnknownProperties' => $this->ignoreUnknownProperties, + 'treatNullAsEmptyCollection' => $this->treatNullAsEmptyCollection, + 'defaultDateFormat' => $this->defaultDateFormat, + 'allowScalarToObjectCasting' => $this->allowScalarToObjectCasting, + ]; + } + + /** + * Indicates whether strict mode is enabled. + */ + public function isStrictMode(): bool + { + return $this->strictMode; + } + + /** + * Indicates whether unknown properties should be ignored during mapping. + */ + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + /** + * Indicates whether null collections should be treated as empty collections. + */ + public function shouldTreatNullAsEmptyCollection(): bool + { + return $this->treatNullAsEmptyCollection; + } + + /** + * Returns the default date format used by the mapper. + */ + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + /** + * Indicates whether scalar values should be cast to objects when possible. + */ + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + + /** + * Returns a copy with the strict mode flag toggled. + */ + public function withStrictMode(bool $enabled): self + { + $clone = clone $this; + $clone->strictMode = $enabled; + + return $clone; + } + + /** + * Returns a copy with the ignore-unknown-properties flag toggled. + */ + public function withIgnoreUnknownProperties(bool $enabled): self + { + $clone = clone $this; + $clone->ignoreUnknownProperties = $enabled; + + return $clone; + } + + /** + * Returns a copy with the treat-null-as-empty-collection flag toggled. + */ + public function withTreatNullAsEmptyCollection(bool $enabled): self + { + $clone = clone $this; + $clone->treatNullAsEmptyCollection = $enabled; + + return $clone; + } + + /** + * Returns a copy with a different default date format. + */ + public function withDefaultDateFormat(string $format): self + { + $clone = clone $this; + $clone->defaultDateFormat = $format; + + return $clone; + } + + /** + * Returns a copy with the scalar-to-object casting flag toggled. + */ + public function withScalarToObjectCasting(bool $enabled): self + { + $clone = clone $this; + $clone->allowScalarToObjectCasting = $enabled; + + return $clone; + } +} diff --git a/src/JsonMapper/Configuration/MappingConfiguration.php b/src/JsonMapper/Configuration/MappingConfiguration.php index 737a6fe..7998cbe 100644 --- a/src/JsonMapper/Configuration/MappingConfiguration.php +++ b/src/JsonMapper/Configuration/MappingConfiguration.php @@ -11,6 +11,7 @@ namespace MagicSunday\JsonMapper\Configuration; +use DateTimeInterface; use MagicSunday\JsonMapper\Context\MappingContext; /** @@ -22,12 +23,16 @@ public function __construct( private bool $strictMode = false, private bool $collectErrors = true, private bool $emptyStringIsNull = false, + private bool $ignoreUnknownProperties = false, + private bool $treatNullAsEmptyCollection = false, + private string $defaultDateFormat = DateTimeInterface::ATOM, + private bool $allowScalarToObjectCasting = false, ) { } public static function lenient(): self { - return new self(false, true); + return new self(); } public static function strict(): self @@ -41,6 +46,10 @@ public static function fromContext(MappingContext $context): self $context->isStrictMode(), $context->shouldCollectErrors(), (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false), + $context->shouldIgnoreUnknownProperties(), + $context->shouldTreatNullAsEmptyCollection(), + $context->getDefaultDateFormat(), + $context->shouldAllowScalarToObjectCasting(), ); } @@ -60,6 +69,38 @@ public function withEmptyStringAsNull(bool $enabled): self return $clone; } + public function withIgnoreUnknownProperties(bool $enabled): self + { + $clone = clone $this; + $clone->ignoreUnknownProperties = $enabled; + + return $clone; + } + + public function withTreatNullAsEmptyCollection(bool $enabled): self + { + $clone = clone $this; + $clone->treatNullAsEmptyCollection = $enabled; + + return $clone; + } + + public function withDefaultDateFormat(string $format): self + { + $clone = clone $this; + $clone->defaultDateFormat = $format; + + return $clone; + } + + public function withScalarToObjectCasting(bool $enabled): self + { + $clone = clone $this; + $clone->allowScalarToObjectCasting = $enabled; + + return $clone; + } + public function isStrictMode(): bool { return $this->strictMode; @@ -75,15 +116,39 @@ public function shouldTreatEmptyStringAsNull(): bool return $this->emptyStringIsNull; } + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + public function shouldTreatNullAsEmptyCollection(): bool + { + return $this->treatNullAsEmptyCollection; + } + + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + /** - * @return array + * @return array */ public function toOptions(): array { return [ - MappingContext::OPTION_STRICT_MODE => $this->strictMode, - MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => $this->ignoreUnknownProperties, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => $this->treatNullAsEmptyCollection, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => $this->defaultDateFormat, + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => $this->allowScalarToObjectCasting, ]; } } diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 510dd2d..b9ecf8b 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -11,19 +11,26 @@ namespace MagicSunday\JsonMapper\Context; +use DateTimeInterface; use MagicSunday\JsonMapper\Exception\MappingException; use function array_slice; use function count; +use function implode; +use function is_string; /** * Represents the state shared while mapping JSON structures. */ final class MappingContext { - public const OPTION_STRICT_MODE = 'strict_mode'; - public const OPTION_COLLECT_ERRORS = 'collect_errors'; - public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; + public const OPTION_STRICT_MODE = 'strict_mode'; + public const OPTION_COLLECT_ERRORS = 'collect_errors'; + public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; + public const OPTION_IGNORE_UNKNOWN_PROPERTIES = 'ignore_unknown_properties'; + public const OPTION_TREAT_NULL_AS_EMPTY_COLLECTION = 'treat_null_as_empty_collection'; + public const OPTION_DEFAULT_DATE_FORMAT = 'default_date_format'; + public const OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING = 'allow_scalar_to_object_casting'; /** * @var list @@ -128,6 +135,32 @@ public function isStrictMode(): bool return (bool) ($this->options[self::OPTION_STRICT_MODE] ?? false); } + public function shouldIgnoreUnknownProperties(): bool + { + return (bool) ($this->options[self::OPTION_IGNORE_UNKNOWN_PROPERTIES] ?? false); + } + + public function shouldTreatNullAsEmptyCollection(): bool + { + return (bool) ($this->options[self::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION] ?? false); + } + + public function getDefaultDateFormat(): string + { + $format = $this->options[self::OPTION_DEFAULT_DATE_FORMAT] ?? DateTimeInterface::ATOM; + + if (!is_string($format) || $format === '') { + return DateTimeInterface::ATOM; + } + + return $format; + } + + public function shouldAllowScalarToObjectCasting(): bool + { + return (bool) ($this->options[self::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING] ?? false); + } + /** * Returns all options. * diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php index eaf08be..077e318 100644 --- a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -13,11 +13,11 @@ use DateInterval; use DateTimeImmutable; +use DateTimeInterface; use Exception; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; use function get_debug_type; use function is_a; @@ -29,59 +29,60 @@ */ final class DateTimeValueConversionStrategy implements ValueConversionStrategyInterface { + use ObjectTypeConversionGuardTrait; + public function supports(mixed $value, Type $type, MappingContext $context): bool { - if (!($type instanceof ObjectType)) { - return false; - } + $objectType = $this->extractObjectType($type); - $className = $type->getClassName(); - - if ($className === '') { + if ($objectType === null) { return false; } + $className = $objectType->getClassName(); + return is_a($className, DateTimeImmutable::class, true) || is_a($className, DateInterval::class, true); } public function convert(mixed $value, Type $type, MappingContext $context): mixed { - if (!($type instanceof ObjectType)) { - return $value; - } - - $className = $type->getClassName(); - - if ($value === null) { - if ($type->isNullable()) { - return null; - } - - throw new TypeMismatchException($context->getPath(), $className, 'null'); - } - - if (!is_string($value) && !is_int($value)) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - if (is_a($className, DateInterval::class, true)) { - if (!is_string($value)) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - try { - return new $className($value); - } catch (Exception) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + return $this->convertObjectValue( + $type, + $context, + $value, + static function (string $className, mixed $value) use ($context) { + if (!is_string($value) && !is_int($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + if (is_a($className, DateInterval::class, true)) { + if (!is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + return new $className($value); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + } + + if (is_string($value)) { + $parsed = $className::createFromFormat($context->getDefaultDateFormat(), $value); + + if ($parsed instanceof DateTimeInterface) { + return $parsed; + } + } + + $formatted = is_int($value) ? '@' . $value : $value; + + try { + return new $className($formatted); + } catch (Exception) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } } - } - - $formatted = is_int($value) ? '@' . $value : $value; - - try { - return new $className($formatted); - } catch (Exception) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } + ); } } diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php index 97fd179..0e7b553 100644 --- a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -15,7 +15,6 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; use ValueError; use function enum_exists; @@ -29,18 +28,18 @@ */ final class EnumValueConversionStrategy implements ValueConversionStrategyInterface { + use ObjectTypeConversionGuardTrait; + public function supports(mixed $value, Type $type, MappingContext $context): bool { - if (!($type instanceof ObjectType)) { - return false; - } - - $className = $type->getClassName(); + $objectType = $this->extractObjectType($type); - if ($className === '') { + if ($objectType === null) { return false; } + $className = $objectType->getClassName(); + if (!enum_exists($className)) { return false; } @@ -50,31 +49,24 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo public function convert(mixed $value, Type $type, MappingContext $context): mixed { - if (!($type instanceof ObjectType)) { - return $value; - } - - $className = $type->getClassName(); - - if ($value === null) { - if ($type->isNullable()) { - return null; + return $this->convertObjectValue( + $type, + $context, + $value, + static function (string $className, mixed $value) use ($context) { + if (!is_int($value) && !is_string($value)) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + try { + /** @var BackedEnum $enum */ + $enum = $className::from($value); + } catch (ValueError) { + throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); + } + + return $enum; } - - throw new TypeMismatchException($context->getPath(), $className, 'null'); - } - - if (!is_int($value) && !is_string($value)) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - try { - /** @var BackedEnum $enum */ - $enum = $className::from($value); - } catch (ValueError) { - throw new TypeMismatchException($context->getPath(), $className, get_debug_type($value)); - } - - return $enum; + ); } } diff --git a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php new file mode 100644 index 0000000..0483ad6 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php @@ -0,0 +1,77 @@ +getClassName() === '') { + return null; + } + + return $type; + } + + /** + * Ensures null values comply with the target object's nullability. + */ + private function guardNullableValue(mixed $value, ObjectType $type, MappingContext $context): void + { + if ($value !== null) { + return; + } + + if ($type->isNullable()) { + return; + } + + throw new TypeMismatchException($context->getPath(), $type->getClassName(), 'null'); + } + + /** + * Executes the provided converter when a valid object type is available. + * + * @param callable(string, mixed): mixed $converter + */ + private function convertObjectValue(Type $type, MappingContext $context, mixed $value, callable $converter): mixed + { + $objectType = $this->extractObjectType($type); + + if ($objectType === null) { + return $value; + } + + $this->guardNullableValue($value, $objectType, $context); + + if ($value === null) { + return null; + } + + return $converter($objectType->getClassName(), $value); + } +} diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 1fcc88c..5640f46 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -52,11 +52,13 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe $resolvedClass = $this->classResolver->resolve($className, $value, $context); if (($value !== null) && !is_array($value) && !is_object($value)) { - $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); - $context->recordException($exception); + if (!$context->shouldAllowScalarToObjectCasting()) { + $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); + $context->recordException($exception); - if ($context->isStrictMode()) { - throw $exception; + if ($context->isStrictMode()) { + throw $exception; + } } } diff --git a/tests/JsonMapper/Configuration/JsonMapperConfigTest.php b/tests/JsonMapper/Configuration/JsonMapperConfigTest.php new file mode 100644 index 0000000..21bcdd9 --- /dev/null +++ b/tests/JsonMapper/Configuration/JsonMapperConfigTest.php @@ -0,0 +1,79 @@ +isStrictMode()); + self::assertFalse($config->shouldIgnoreUnknownProperties()); + self::assertFalse($config->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); + self::assertFalse($config->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itSupportsImmutableUpdates(): void + { + $config = new JsonMapperConfig(); + + $modified = $config + ->withStrictMode(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y H:i:s') + ->withScalarToObjectCasting(true); + + self::assertFalse($config->isStrictMode()); + self::assertFalse($config->shouldIgnoreUnknownProperties()); + self::assertFalse($config->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); + self::assertFalse($config->shouldAllowScalarToObjectCasting()); + + self::assertTrue($modified->isStrictMode()); + self::assertTrue($modified->shouldIgnoreUnknownProperties()); + self::assertTrue($modified->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y H:i:s', $modified->getDefaultDateFormat()); + self::assertTrue($modified->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itSerializesAndRestoresItself(): void + { + $config = (new JsonMapperConfig()) + ->withStrictMode(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y') + ->withScalarToObjectCasting(true); + + $restored = JsonMapperConfig::fromArray($config->toArray()); + + self::assertTrue($restored->isStrictMode()); + self::assertTrue($restored->shouldIgnoreUnknownProperties()); + self::assertTrue($restored->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $restored->getDefaultDateFormat()); + self::assertTrue($restored->shouldAllowScalarToObjectCasting()); + } +} diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php index 943af84..aab92cb 100644 --- a/tests/JsonMapper/Configuration/MappingConfigurationTest.php +++ b/tests/JsonMapper/Configuration/MappingConfigurationTest.php @@ -11,6 +11,7 @@ namespace MagicSunday\Test\JsonMapper\Configuration; +use DateTimeInterface; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use PHPUnit\Framework\Attributes\Test; @@ -29,6 +30,10 @@ public function itProvidesLenientDefaults(): void self::assertFalse($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); + self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); } #[Test] @@ -38,6 +43,7 @@ public function itEnablesStrictMode(): void self::assertTrue($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); } #[Test] @@ -58,13 +64,32 @@ public function itSupportsEmptyStringConfiguration(): void self::assertTrue($configuration->withEmptyStringAsNull(true)->shouldTreatEmptyStringAsNull()); } + #[Test] + public function itSupportsExtendedFlags(): void + { + $configuration = MappingConfiguration::lenient() + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y H:i:s') + ->withScalarToObjectCasting(true); + + self::assertTrue($configuration->shouldIgnoreUnknownProperties()); + self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y H:i:s', $configuration->getDefaultDateFormat()); + self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); + } + #[Test] public function itDerivesFromContext(): void { $context = new MappingContext([], [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, ]); $configuration = MappingConfiguration::fromContext($context); @@ -72,11 +97,19 @@ public function itDerivesFromContext(): void self::assertTrue($configuration->isStrictMode()); self::assertTrue($configuration->shouldCollectErrors()); self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertTrue($configuration->shouldIgnoreUnknownProperties()); + self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $configuration->getDefaultDateFormat()); + self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); self::assertSame( [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => true, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, ], $configuration->toOptions(), ); diff --git a/tests/JsonMapper/Context/MappingContextTest.php b/tests/JsonMapper/Context/MappingContextTest.php index dc8b5ce..c71339d 100644 --- a/tests/JsonMapper/Context/MappingContextTest.php +++ b/tests/JsonMapper/Context/MappingContextTest.php @@ -59,4 +59,20 @@ public function itExposesOptions(): void self::assertTrue($context->getOption('flag')); self::assertSame('fallback', $context->getOption('missing', 'fallback')); } + + #[Test] + public function itProvidesTypedOptionAccessors(): void + { + $context = new MappingContext(['root'], [ + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, + ]); + + self::assertTrue($context->shouldIgnoreUnknownProperties()); + self::assertTrue($context->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $context->getDefaultDateFormat()); + self::assertTrue($context->shouldAllowScalarToObjectCasting()); + } } diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index cdaa998..4ec3a31 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -12,7 +12,9 @@ namespace MagicSunday\Test; use DateInterval; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\Test\Classes\Base; use MagicSunday\Test\Classes\BaseCollection; @@ -895,4 +897,90 @@ public function mapEmptyStringToNullWhenEnabled(): void self::assertInstanceOf(NullableStringHolder::class, $result); self::assertNull($result->value); } + + #[Test] + public function itAppliesConfiguredStrictModeByDefault(): void + { + $config = (new JsonMapperConfig())->withStrictMode(true); + + $this->expectException(UnknownPropertyException::class); + + $this->getJsonMapper([], $config)->map( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + ); + } + + #[Test] + public function itIgnoresUnknownPropertiesWhenConfigured(): void + { + $config = (new JsonMapperConfig())->withIgnoreUnknownProperties(true); + + $result = $this->getJsonMapper([], $config) + ->mapWithReport( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + ); + + self::assertInstanceOf(Person::class, $result->getValue()); + self::assertFalse($result->getReport()->hasErrors()); + } + + #[Test] + public function itTreatsNullCollectionsAsEmptyWhenConfigured(): void + { + $config = (new JsonMapperConfig())->withTreatNullAsEmptyCollection(true); + + $result = $this->getJsonMapper([], $config) + ->map( + [ + 'simpleArray' => null, + ], + Base::class, + ); + + self::assertInstanceOf(Base::class, $result); + self::assertSame([], $result->simpleArray); + } + + #[Test] + public function itUsesDefaultDateFormatFromConfiguration(): void + { + $config = (new JsonMapperConfig())->withDefaultDateFormat('d.m.Y H:i:s'); + + $result = $this->getJsonMapper([], $config) + ->map( + [ + 'createdAt' => '24.01.2024 18:45:00', + ], + DateTimeHolder::class, + ); + + self::assertInstanceOf(DateTimeHolder::class, $result); + self::assertSame('24.01.2024 18:45:00', $result->createdAt->format('d.m.Y H:i:s')); + } + + #[Test] + public function itAllowsScalarToObjectCastingWhenConfigured(): void + { + $config = (new JsonMapperConfig())->withScalarToObjectCasting(true); + + $result = $this->getJsonMapper([], $config) + ->mapWithReport( + [ + 'simple' => 'identifier', + ], + Base::class, + ); + + self::assertFalse($result->getReport()->hasErrors()); + $mapped = $result->getValue(); + self::assertInstanceOf(Base::class, $mapped); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 960db0d..cd26409 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,6 +14,7 @@ use Closure; use JsonException; use MagicSunday\JsonMapper; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -33,8 +34,9 @@ class TestCase extends \PHPUnit\Framework\TestCase * Returns an instance of the JsonMapper for testing. * * @param array $classMap + * @param JsonMapperConfig|null $config */ - protected function getJsonMapper(array $classMap = []): JsonMapper + protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config = null): JsonMapper { $listExtractors = [new ReflectionExtractor()]; $typeExtractors = [new PhpDocExtractor()]; @@ -45,6 +47,8 @@ protected function getJsonMapper(array $classMap = []): JsonMapper PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), $classMap, + null, + $config, ); } From 44f6f43c905147fb295c8c12f082d0963810b001 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 07:39:07 +0100 Subject: [PATCH 19/46] Ensure JsonMapper defaults avoid nullable config --- src/JsonMapper.php | 21 +++++++------------ src/JsonMapper/Attribute/ReplaceProperty.php | 6 +++--- .../CollectionDocBlockTypeResolver.php | 12 +++-------- src/JsonMapper/Context/MappingContext.php | 20 +++++++++++------- src/JsonMapper/Context/MappingError.php | 8 +++---- src/JsonMapper/Report/MappingReport.php | 4 ++-- src/JsonMapper/Report/MappingResult.php | 6 +++--- src/JsonMapper/Type/TypeResolver.php | 12 ++++------- .../BuiltinValueConversionStrategy.php | 3 +-- .../DateTimeValueConversionStrategy.php | 3 ++- .../Strategy/EnumValueConversionStrategy.php | 3 ++- .../ObjectValueConversionStrategy.php | 14 ++++++------- tests/TestCase.php | 2 +- 13 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index f2b08aa..543f02a 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -105,8 +105,6 @@ class JsonMapper private CustomTypeRegistry $customTypeRegistry; - private JsonMapperConfig $config; - /** * @param array $classMap * @param CacheItemPoolInterface|null $typeCache @@ -119,9 +117,8 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - ?JsonMapperConfig $config = null, + private JsonMapperConfig $config = new JsonMapperConfig(), ) { - $this->config = $config ?? new JsonMapperConfig(); $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); $this->customTypeRegistry = new CustomTypeRegistry(); @@ -212,15 +209,13 @@ public function map( ?MappingContext $context = null, ?MappingConfiguration $configuration = null, ): mixed { - if ($context === null) { - $configuration = $configuration ?? $this->createDefaultConfiguration(); - $context = new MappingContext($json, $configuration->toOptions()); + if (!$context instanceof MappingContext) { + $configuration ??= $this->createDefaultConfiguration(); + $context = new MappingContext($json, $configuration->toOptions()); + } elseif (!$configuration instanceof MappingConfiguration) { + $configuration = MappingConfiguration::fromContext($context); } else { - if ($configuration === null) { - $configuration = MappingConfiguration::fromContext($context); - } else { - $context->replaceOptions($configuration->toOptions()); - } + $context->replaceOptions($configuration->toOptions()); } $resolvedClassName = $className === null @@ -418,7 +413,7 @@ private function createDefaultConfiguration(): MappingConfiguration $configuration = $configuration->withDefaultDateFormat($this->config->getDefaultDateFormat()); if ($this->config->shouldAllowScalarToObjectCasting()) { - $configuration = $configuration->withScalarToObjectCasting(true); + return $configuration->withScalarToObjectCasting(true); } return $configuration; diff --git a/src/JsonMapper/Attribute/ReplaceProperty.php b/src/JsonMapper/Attribute/ReplaceProperty.php index d4e5af3..cff49a6 100644 --- a/src/JsonMapper/Attribute/ReplaceProperty.php +++ b/src/JsonMapper/Attribute/ReplaceProperty.php @@ -17,11 +17,11 @@ * Attribute used to instruct the mapper to rename a JSON field. */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -final class ReplaceProperty +final readonly class ReplaceProperty { public function __construct( - public readonly string $value, - public readonly string $replaces, + public string $value, + public string $replaces, ) { } } diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php index 63a08e2..cb0cbd0 100644 --- a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -28,14 +28,10 @@ final class CollectionDocBlockTypeResolver { private DocBlockFactoryInterface $docBlockFactory; - private ContextFactory $contextFactory; - - private PhpDocTypeHelper $phpDocTypeHelper; - public function __construct( ?DocBlockFactoryInterface $docBlockFactory = null, - ?ContextFactory $contextFactory = null, - ?PhpDocTypeHelper $phpDocTypeHelper = null, + private ContextFactory $contextFactory = new ContextFactory(), + private PhpDocTypeHelper $phpDocTypeHelper = new PhpDocTypeHelper(), ) { if (!class_exists(DocBlockFactory::class)) { throw new LogicException( @@ -46,9 +42,7 @@ public function __construct( ); } - $this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); - $this->contextFactory = $contextFactory ?? new ContextFactory(); - $this->phpDocTypeHelper = $phpDocTypeHelper ?? new PhpDocTypeHelper(); + $this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); } /** diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index b9ecf8b..352fe78 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -24,13 +24,19 @@ */ final class MappingContext { - public const OPTION_STRICT_MODE = 'strict_mode'; - public const OPTION_COLLECT_ERRORS = 'collect_errors'; - public const OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; - public const OPTION_IGNORE_UNKNOWN_PROPERTIES = 'ignore_unknown_properties'; - public const OPTION_TREAT_NULL_AS_EMPTY_COLLECTION = 'treat_null_as_empty_collection'; - public const OPTION_DEFAULT_DATE_FORMAT = 'default_date_format'; - public const OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING = 'allow_scalar_to_object_casting'; + public const string OPTION_STRICT_MODE = 'strict_mode'; + + public const string OPTION_COLLECT_ERRORS = 'collect_errors'; + + public const string OPTION_TREAT_EMPTY_STRING_AS_NULL = 'empty_string_is_null'; + + public const string OPTION_IGNORE_UNKNOWN_PROPERTIES = 'ignore_unknown_properties'; + + public const string OPTION_TREAT_NULL_AS_EMPTY_COLLECTION = 'treat_null_as_empty_collection'; + + public const string OPTION_DEFAULT_DATE_FORMAT = 'default_date_format'; + + public const string OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING = 'allow_scalar_to_object_casting'; /** * @var list diff --git a/src/JsonMapper/Context/MappingError.php b/src/JsonMapper/Context/MappingError.php index 6bcc624..568bcbe 100644 --- a/src/JsonMapper/Context/MappingError.php +++ b/src/JsonMapper/Context/MappingError.php @@ -16,12 +16,12 @@ /** * Represents a collected mapping error. */ -final class MappingError +final readonly class MappingError { public function __construct( - private readonly string $path, - private readonly string $message, - private readonly ?MappingException $exception = null, + private string $path, + private string $message, + private ?MappingException $exception = null, ) { } diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php index 9195094..886a41e 100644 --- a/src/JsonMapper/Report/MappingReport.php +++ b/src/JsonMapper/Report/MappingReport.php @@ -16,12 +16,12 @@ /** * Represents the result of collecting mapping errors. */ -final class MappingReport +final readonly class MappingReport { /** * @param list $errors */ - public function __construct(private readonly array $errors) + public function __construct(private array $errors) { } diff --git a/src/JsonMapper/Report/MappingResult.php b/src/JsonMapper/Report/MappingResult.php index 0861e22..c6056d0 100644 --- a/src/JsonMapper/Report/MappingResult.php +++ b/src/JsonMapper/Report/MappingResult.php @@ -14,11 +14,11 @@ /** * Represents the outcome of a mapping operation and its report. */ -final class MappingResult +final readonly class MappingResult { public function __construct( - private readonly mixed $value, - private readonly MappingReport $report, + private mixed $value, + private MappingReport $report, ) { } diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index d004e8b..a6acbc8 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -28,7 +28,7 @@ */ final class TypeResolver { - private const CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; + private const string CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; private BuiltinType $defaultType; @@ -61,11 +61,7 @@ public function resolve(string $className, string $propertyName): Type $type = $this->resolveFromReflection($className, $propertyName); } - if ($type instanceof Type) { - $resolved = $this->normalizeType($type); - } else { - $resolved = $this->defaultType; - } + $resolved = $type instanceof Type ? $this->normalizeType($type) : $this->defaultType; $this->storeCachedType($className, $propertyName, $resolved); @@ -91,7 +87,7 @@ private function normalizeType(Type $type): Type */ private function getCachedType(string $className, string $propertyName): ?Type { - if ($this->cache === null) { + if (!$this->cache instanceof CacheItemPoolInterface) { return null; } @@ -119,7 +115,7 @@ private function getCachedType(string $className, string $propertyName): ?Type */ private function storeCachedType(string $className, string $propertyName, Type $type): void { - if ($this->cache === null) { + if (!$this->cache instanceof CacheItemPoolInterface) { return; } diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index 74f65d7..4eacbbb 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -16,7 +16,6 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\TypeIdentifier; -use Traversable; use function assert; use function filter_var; @@ -169,7 +168,7 @@ private function isCompatibleValue(mixed $value, TypeIdentifier $identifier): bo 'array' => is_array($value), 'object' => is_object($value), 'callable' => is_callable($value), - 'iterable' => is_array($value) || $value instanceof Traversable, + 'iterable' => is_iterable($value), 'null' => $value === null, default => true, }; diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php index 077e318..ac60f44 100644 --- a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -18,6 +18,7 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; use function get_debug_type; use function is_a; @@ -35,7 +36,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo { $objectType = $this->extractObjectType($type); - if ($objectType === null) { + if (!$objectType instanceof ObjectType) { return false; } diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php index 0e7b553..1672d55 100644 --- a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -15,6 +15,7 @@ use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Exception\TypeMismatchException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; use ValueError; use function enum_exists; @@ -34,7 +35,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo { $objectType = $this->extractObjectType($type); - if ($objectType === null) { + if (!$objectType instanceof ObjectType) { return false; } diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 5640f46..717ff35 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -51,14 +51,12 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe $className = $this->resolveClassName($type); $resolvedClass = $this->classResolver->resolve($className, $value, $context); - if (($value !== null) && !is_array($value) && !is_object($value)) { - if (!$context->shouldAllowScalarToObjectCasting()) { - $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); - $context->recordException($exception); - - if ($context->isStrictMode()) { - throw $exception; - } + if ($value !== null && !is_array($value) && !is_object($value) && !$context->shouldAllowScalarToObjectCasting()) { + $exception = new TypeMismatchException($context->getPath(), $resolvedClass, get_debug_type($value)); + $context->recordException($exception); + + if ($context->isStrictMode()) { + throw $exception; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index cd26409..19eac96 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -48,7 +48,7 @@ protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config new CamelCasePropertyNameConverter(), $classMap, null, - $config, + $config ?? new JsonMapperConfig(), ); } From 55078c057f8e95d64c8fe0653fa704eeba558501 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 07:54:15 +0100 Subject: [PATCH 20/46] refactor: unify mapper configuration --- src/JsonMapper.php | 54 ++--- .../Configuration/JsonMapperConfig.php | 167 -------------- ...ration.php => JsonMapperConfiguration.php} | 216 +++++++++++++----- .../Configuration/JsonMapperConfigTest.php | 79 ------- .../JsonMapperConfigurationTest.php | 135 +++++++++++ .../MappingConfigurationTest.php | 117 ---------- .../JsonMapperErrorHandlingTest.php | 12 +- tests/JsonMapperTest.php | 15 +- tests/TestCase.php | 8 +- 9 files changed, 333 insertions(+), 470 deletions(-) delete mode 100644 src/JsonMapper/Configuration/JsonMapperConfig.php rename src/JsonMapper/Configuration/{MappingConfiguration.php => JsonMapperConfiguration.php} (54%) delete mode 100644 tests/JsonMapper/Configuration/JsonMapperConfigTest.php create mode 100644 tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php delete mode 100644 tests/JsonMapper/Configuration/MappingConfigurationTest.php diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 543f02a..3f29a33 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -18,8 +18,7 @@ use MagicSunday\JsonMapper\Collection\CollectionDocBlockTypeResolver; use MagicSunday\JsonMapper\Collection\CollectionFactory; use MagicSunday\JsonMapper\Collection\CollectionFactoryInterface; -use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; -use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Context\MappingContext; use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; use MagicSunday\JsonMapper\Exception\MappingException; @@ -117,7 +116,7 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - private JsonMapperConfig $config = new JsonMapperConfig(), + private JsonMapperConfiguration $config = new JsonMapperConfiguration(), ) { $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); @@ -145,7 +144,7 @@ function (string $className, ?array $arguments): object { new ObjectValueConversionStrategy( $this->classResolver, function (mixed $value, string $resolvedClass, MappingContext $context): mixed { - $configuration = MappingConfiguration::fromContext($context); + $configuration = JsonMapperConfiguration::fromContext($context); return $this->map($value, $resolvedClass, null, $context, $configuration); }, @@ -196,9 +195,11 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName + * @param MappingContext|null $context + * @param JsonMapperConfiguration|null $configuration * * @throws InvalidArgumentException */ @@ -207,13 +208,13 @@ public function map( ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, - ?MappingConfiguration $configuration = null, + ?JsonMapperConfiguration $configuration = null, ): mixed { if (!$context instanceof MappingContext) { $configuration ??= $this->createDefaultConfiguration(); $context = new MappingContext($json, $configuration->toOptions()); - } elseif (!$configuration instanceof MappingConfiguration) { - $configuration = MappingConfiguration::fromContext($context); + } elseif (!$configuration instanceof JsonMapperConfiguration) { + $configuration = JsonMapperConfiguration::fromContext($context); } else { $context->replaceOptions($configuration->toOptions()); } @@ -379,15 +380,16 @@ public function map( /** * Maps the JSON structure and returns a detailed mapping report. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName + * @param mixed $json + * @param class-string|null $className + * @param class-string|null $collectionClassName + * @param JsonMapperConfiguration|null $configuration */ public function mapWithReport( mixed $json, ?string $className = null, ?string $collectionClassName = null, - ?MappingConfiguration $configuration = null, + ?JsonMapperConfiguration $configuration = null, ): MappingResult { $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); $context = new MappingContext($json, $configuration->toOptions()); @@ -396,27 +398,9 @@ public function mapWithReport( return new MappingResult($value, new MappingReport($context->getErrorRecords())); } - private function createDefaultConfiguration(): MappingConfiguration + private function createDefaultConfiguration(): JsonMapperConfiguration { - $configuration = $this->config->isStrictMode() - ? MappingConfiguration::strict() - : MappingConfiguration::lenient(); - - if ($this->config->shouldIgnoreUnknownProperties()) { - $configuration = $configuration->withIgnoreUnknownProperties(true); - } - - if ($this->config->shouldTreatNullAsEmptyCollection()) { - $configuration = $configuration->withTreatNullAsEmptyCollection(true); - } - - $configuration = $configuration->withDefaultDateFormat($this->config->getDefaultDateFormat()); - - if ($this->config->shouldAllowScalarToObjectCasting()) { - return $configuration->withScalarToObjectCasting(true); - } - - return $configuration; + return clone $this->config; } /** @@ -470,7 +454,7 @@ private function isRequiredProperty(string $className, string $propertyName): bo return false; } - private function handleMappingException(MappingException $exception, MappingContext $context, MappingConfiguration $configuration): void + private function handleMappingException(MappingException $exception, MappingContext $context, JsonMapperConfiguration $configuration): void { $context->recordException($exception); diff --git a/src/JsonMapper/Configuration/JsonMapperConfig.php b/src/JsonMapper/Configuration/JsonMapperConfig.php deleted file mode 100644 index 807ae15..0000000 --- a/src/JsonMapper/Configuration/JsonMapperConfig.php +++ /dev/null @@ -1,167 +0,0 @@ - $data Configuration values indexed by property name - */ - public static function fromArray(array $data): self - { - $defaultDateFormat = $data['defaultDateFormat'] ?? DateTimeInterface::ATOM; - - if (!is_string($defaultDateFormat)) { - $defaultDateFormat = DateTimeInterface::ATOM; - } - - return new self( - (bool) ($data['strictMode'] ?? false), - (bool) ($data['ignoreUnknownProperties'] ?? false), - (bool) ($data['treatNullAsEmptyCollection'] ?? false), - $defaultDateFormat, - (bool) ($data['allowScalarToObjectCasting'] ?? false), - ); - } - - /** - * Serializes the configuration into an array representation. - * - * @return array - */ - public function toArray(): array - { - return [ - 'strictMode' => $this->strictMode, - 'ignoreUnknownProperties' => $this->ignoreUnknownProperties, - 'treatNullAsEmptyCollection' => $this->treatNullAsEmptyCollection, - 'defaultDateFormat' => $this->defaultDateFormat, - 'allowScalarToObjectCasting' => $this->allowScalarToObjectCasting, - ]; - } - - /** - * Indicates whether strict mode is enabled. - */ - public function isStrictMode(): bool - { - return $this->strictMode; - } - - /** - * Indicates whether unknown properties should be ignored during mapping. - */ - public function shouldIgnoreUnknownProperties(): bool - { - return $this->ignoreUnknownProperties; - } - - /** - * Indicates whether null collections should be treated as empty collections. - */ - public function shouldTreatNullAsEmptyCollection(): bool - { - return $this->treatNullAsEmptyCollection; - } - - /** - * Returns the default date format used by the mapper. - */ - public function getDefaultDateFormat(): string - { - return $this->defaultDateFormat; - } - - /** - * Indicates whether scalar values should be cast to objects when possible. - */ - public function shouldAllowScalarToObjectCasting(): bool - { - return $this->allowScalarToObjectCasting; - } - - /** - * Returns a copy with the strict mode flag toggled. - */ - public function withStrictMode(bool $enabled): self - { - $clone = clone $this; - $clone->strictMode = $enabled; - - return $clone; - } - - /** - * Returns a copy with the ignore-unknown-properties flag toggled. - */ - public function withIgnoreUnknownProperties(bool $enabled): self - { - $clone = clone $this; - $clone->ignoreUnknownProperties = $enabled; - - return $clone; - } - - /** - * Returns a copy with the treat-null-as-empty-collection flag toggled. - */ - public function withTreatNullAsEmptyCollection(bool $enabled): self - { - $clone = clone $this; - $clone->treatNullAsEmptyCollection = $enabled; - - return $clone; - } - - /** - * Returns a copy with a different default date format. - */ - public function withDefaultDateFormat(string $format): self - { - $clone = clone $this; - $clone->defaultDateFormat = $format; - - return $clone; - } - - /** - * Returns a copy with the scalar-to-object casting flag toggled. - */ - public function withScalarToObjectCasting(bool $enabled): self - { - $clone = clone $this; - $clone->allowScalarToObjectCasting = $enabled; - - return $clone; - } -} diff --git a/src/JsonMapper/Configuration/MappingConfiguration.php b/src/JsonMapper/Configuration/JsonMapperConfiguration.php similarity index 54% rename from src/JsonMapper/Configuration/MappingConfiguration.php rename to src/JsonMapper/Configuration/JsonMapperConfiguration.php index 7998cbe..e06578f 100644 --- a/src/JsonMapper/Configuration/MappingConfiguration.php +++ b/src/JsonMapper/Configuration/JsonMapperConfiguration.php @@ -14,11 +14,16 @@ use DateTimeInterface; use MagicSunday\JsonMapper\Context\MappingContext; +use function is_string; + /** - * Defines configuration options for mapping operations. + * Defines all configurable options available for JsonMapper. */ -final class MappingConfiguration +final class JsonMapperConfiguration { + /** + * Creates a new configuration instance with optional overrides. + */ public function __construct( private bool $strictMode = false, private bool $collectErrors = true, @@ -30,16 +35,49 @@ public function __construct( ) { } + /** + * Returns a lenient configuration with default settings. + */ public static function lenient(): self { return new self(); } + /** + * Returns a strict configuration that reports unknown and missing properties. + */ public static function strict(): self { - return new self(true, true); + return new self(true); } + /** + * Restores a configuration instance from the provided array. + * + * @param array $data Configuration values indexed by property name + */ + public static function fromArray(array $data): self + { + $defaultDateFormat = $data['defaultDateFormat'] ?? DateTimeInterface::ATOM; + + if (!is_string($defaultDateFormat) || $defaultDateFormat === '') { + $defaultDateFormat = DateTimeInterface::ATOM; + } + + return new self( + (bool) ($data['strictMode'] ?? false), + (bool) ($data['collectErrors'] ?? true), + (bool) ($data['emptyStringIsNull'] ?? false), + (bool) ($data['ignoreUnknownProperties'] ?? false), + (bool) ($data['treatNullAsEmptyCollection'] ?? false), + $defaultDateFormat, + (bool) ($data['allowScalarToObjectCasting'] ?? false), + ); + } + + /** + * Restores a configuration instance based on the provided mapping context. + */ public static function fromContext(MappingContext $context): self { return new self( @@ -53,6 +91,112 @@ public static function fromContext(MappingContext $context): self ); } + /** + * Serializes the configuration into an array representation. + * + * @return array + */ + public function toArray(): array + { + return [ + 'strictMode' => $this->strictMode, + 'collectErrors' => $this->collectErrors, + 'emptyStringIsNull' => $this->emptyStringIsNull, + 'ignoreUnknownProperties' => $this->ignoreUnknownProperties, + 'treatNullAsEmptyCollection' => $this->treatNullAsEmptyCollection, + 'defaultDateFormat' => $this->defaultDateFormat, + 'allowScalarToObjectCasting' => $this->allowScalarToObjectCasting, + ]; + } + + /** + * Converts the configuration to mapping context options. + * + * @return array + */ + public function toOptions(): array + { + return [ + MappingContext::OPTION_STRICT_MODE => $this->strictMode, + MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => $this->ignoreUnknownProperties, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => $this->treatNullAsEmptyCollection, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => $this->defaultDateFormat, + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => $this->allowScalarToObjectCasting, + ]; + } + + /** + * Indicates whether strict mode is enabled. + */ + public function isStrictMode(): bool + { + return $this->strictMode; + } + + /** + * Indicates whether errors should be collected during mapping. + */ + public function shouldCollectErrors(): bool + { + return $this->collectErrors; + } + + /** + * Indicates whether empty strings should be treated as null values. + */ + public function shouldTreatEmptyStringAsNull(): bool + { + return $this->emptyStringIsNull; + } + + /** + * Indicates whether unknown properties should be ignored. + */ + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + /** + * Indicates whether null collections should be converted to empty collections. + */ + public function shouldTreatNullAsEmptyCollection(): bool + { + return $this->treatNullAsEmptyCollection; + } + + /** + * Returns the default date format used for date conversions. + */ + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + /** + * Indicates whether scalar values may be cast to objects. + */ + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + + /** + * Returns a copy with the strict mode flag toggled. + */ + public function withStrictMode(bool $enabled): self + { + $clone = clone $this; + $clone->strictMode = $enabled; + + return $clone; + } + + /** + * Returns a copy with the error collection flag toggled. + */ public function withErrorCollection(bool $collect): self { $clone = clone $this; @@ -61,6 +205,9 @@ public function withErrorCollection(bool $collect): self return $clone; } + /** + * Returns a copy with the empty-string-as-null flag toggled. + */ public function withEmptyStringAsNull(bool $enabled): self { $clone = clone $this; @@ -69,6 +216,9 @@ public function withEmptyStringAsNull(bool $enabled): self return $clone; } + /** + * Returns a copy with the ignore-unknown-properties flag toggled. + */ public function withIgnoreUnknownProperties(bool $enabled): self { $clone = clone $this; @@ -77,6 +227,9 @@ public function withIgnoreUnknownProperties(bool $enabled): self return $clone; } + /** + * Returns a copy with the treat-null-as-empty-collection flag toggled. + */ public function withTreatNullAsEmptyCollection(bool $enabled): self { $clone = clone $this; @@ -85,6 +238,9 @@ public function withTreatNullAsEmptyCollection(bool $enabled): self return $clone; } + /** + * Returns a copy with a different default date format. + */ public function withDefaultDateFormat(string $format): self { $clone = clone $this; @@ -93,6 +249,9 @@ public function withDefaultDateFormat(string $format): self return $clone; } + /** + * Returns a copy with the scalar-to-object casting flag toggled. + */ public function withScalarToObjectCasting(bool $enabled): self { $clone = clone $this; @@ -100,55 +259,4 @@ public function withScalarToObjectCasting(bool $enabled): self return $clone; } - - public function isStrictMode(): bool - { - return $this->strictMode; - } - - public function shouldCollectErrors(): bool - { - return $this->collectErrors; - } - - public function shouldTreatEmptyStringAsNull(): bool - { - return $this->emptyStringIsNull; - } - - public function shouldIgnoreUnknownProperties(): bool - { - return $this->ignoreUnknownProperties; - } - - public function shouldTreatNullAsEmptyCollection(): bool - { - return $this->treatNullAsEmptyCollection; - } - - public function getDefaultDateFormat(): string - { - return $this->defaultDateFormat; - } - - public function shouldAllowScalarToObjectCasting(): bool - { - return $this->allowScalarToObjectCasting; - } - - /** - * @return array - */ - public function toOptions(): array - { - return [ - MappingContext::OPTION_STRICT_MODE => $this->strictMode, - MappingContext::OPTION_COLLECT_ERRORS => $this->collectErrors, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => $this->emptyStringIsNull, - MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => $this->ignoreUnknownProperties, - MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => $this->treatNullAsEmptyCollection, - MappingContext::OPTION_DEFAULT_DATE_FORMAT => $this->defaultDateFormat, - MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => $this->allowScalarToObjectCasting, - ]; - } } diff --git a/tests/JsonMapper/Configuration/JsonMapperConfigTest.php b/tests/JsonMapper/Configuration/JsonMapperConfigTest.php deleted file mode 100644 index 21bcdd9..0000000 --- a/tests/JsonMapper/Configuration/JsonMapperConfigTest.php +++ /dev/null @@ -1,79 +0,0 @@ -isStrictMode()); - self::assertFalse($config->shouldIgnoreUnknownProperties()); - self::assertFalse($config->shouldTreatNullAsEmptyCollection()); - self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); - self::assertFalse($config->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itSupportsImmutableUpdates(): void - { - $config = new JsonMapperConfig(); - - $modified = $config - ->withStrictMode(true) - ->withIgnoreUnknownProperties(true) - ->withTreatNullAsEmptyCollection(true) - ->withDefaultDateFormat('d.m.Y H:i:s') - ->withScalarToObjectCasting(true); - - self::assertFalse($config->isStrictMode()); - self::assertFalse($config->shouldIgnoreUnknownProperties()); - self::assertFalse($config->shouldTreatNullAsEmptyCollection()); - self::assertSame(DateTimeInterface::ATOM, $config->getDefaultDateFormat()); - self::assertFalse($config->shouldAllowScalarToObjectCasting()); - - self::assertTrue($modified->isStrictMode()); - self::assertTrue($modified->shouldIgnoreUnknownProperties()); - self::assertTrue($modified->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y H:i:s', $modified->getDefaultDateFormat()); - self::assertTrue($modified->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itSerializesAndRestoresItself(): void - { - $config = (new JsonMapperConfig()) - ->withStrictMode(true) - ->withIgnoreUnknownProperties(true) - ->withTreatNullAsEmptyCollection(true) - ->withDefaultDateFormat('d.m.Y') - ->withScalarToObjectCasting(true); - - $restored = JsonMapperConfig::fromArray($config->toArray()); - - self::assertTrue($restored->isStrictMode()); - self::assertTrue($restored->shouldIgnoreUnknownProperties()); - self::assertTrue($restored->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y', $restored->getDefaultDateFormat()); - self::assertTrue($restored->shouldAllowScalarToObjectCasting()); - } -} diff --git a/tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php b/tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php new file mode 100644 index 0000000..89c6e96 --- /dev/null +++ b/tests/JsonMapper/Configuration/JsonMapperConfigurationTest.php @@ -0,0 +1,135 @@ +isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); + self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itProvidesStrictPreset(): void + { + $configuration = JsonMapperConfiguration::strict(); + + self::assertTrue($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + } + + #[Test] + public function itSupportsImmutableUpdates(): void + { + $configuration = JsonMapperConfiguration::lenient(); + + $updated = $configuration + ->withStrictMode(true) + ->withErrorCollection(false) + ->withEmptyStringAsNull(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y H:i:s') + ->withScalarToObjectCasting(true); + + self::assertFalse($configuration->isStrictMode()); + self::assertTrue($configuration->shouldCollectErrors()); + self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); + self::assertFalse($configuration->shouldIgnoreUnknownProperties()); + self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); + self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); + + self::assertTrue($updated->isStrictMode()); + self::assertFalse($updated->shouldCollectErrors()); + self::assertTrue($updated->shouldTreatEmptyStringAsNull()); + self::assertTrue($updated->shouldIgnoreUnknownProperties()); + self::assertTrue($updated->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y H:i:s', $updated->getDefaultDateFormat()); + self::assertTrue($updated->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itSerializesAndRestoresFromArrays(): void + { + $configuration = JsonMapperConfiguration::lenient() + ->withStrictMode(true) + ->withErrorCollection(false) + ->withEmptyStringAsNull(true) + ->withIgnoreUnknownProperties(true) + ->withTreatNullAsEmptyCollection(true) + ->withDefaultDateFormat('d.m.Y') + ->withScalarToObjectCasting(true); + + $restored = JsonMapperConfiguration::fromArray($configuration->toArray()); + + self::assertTrue($restored->isStrictMode()); + self::assertFalse($restored->shouldCollectErrors()); + self::assertTrue($restored->shouldTreatEmptyStringAsNull()); + self::assertTrue($restored->shouldIgnoreUnknownProperties()); + self::assertTrue($restored->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $restored->getDefaultDateFormat()); + self::assertTrue($restored->shouldAllowScalarToObjectCasting()); + } + + #[Test] + public function itRestoresFromContext(): void + { + $context = new MappingContext([], [ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => false, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => true, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, + ]); + + $configuration = JsonMapperConfiguration::fromContext($context); + + self::assertTrue($configuration->isStrictMode()); + self::assertFalse($configuration->shouldCollectErrors()); + self::assertTrue($configuration->shouldTreatEmptyStringAsNull()); + self::assertTrue($configuration->shouldIgnoreUnknownProperties()); + self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); + self::assertSame('d.m.Y', $configuration->getDefaultDateFormat()); + self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); + self::assertSame([ + MappingContext::OPTION_STRICT_MODE => true, + MappingContext::OPTION_COLLECT_ERRORS => false, + MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => true, + MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, + MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, + MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', + MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, + ], $configuration->toOptions()); + } +} diff --git a/tests/JsonMapper/Configuration/MappingConfigurationTest.php b/tests/JsonMapper/Configuration/MappingConfigurationTest.php deleted file mode 100644 index aab92cb..0000000 --- a/tests/JsonMapper/Configuration/MappingConfigurationTest.php +++ /dev/null @@ -1,117 +0,0 @@ -isStrictMode()); - self::assertTrue($configuration->shouldCollectErrors()); - self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); - self::assertFalse($configuration->shouldIgnoreUnknownProperties()); - self::assertFalse($configuration->shouldTreatNullAsEmptyCollection()); - self::assertSame(DateTimeInterface::ATOM, $configuration->getDefaultDateFormat()); - self::assertFalse($configuration->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itEnablesStrictMode(): void - { - $configuration = MappingConfiguration::strict(); - - self::assertTrue($configuration->isStrictMode()); - self::assertTrue($configuration->shouldCollectErrors()); - self::assertFalse($configuration->shouldIgnoreUnknownProperties()); - } - - #[Test] - public function itSupportsTogglingErrorCollection(): void - { - $configuration = MappingConfiguration::lenient()->withErrorCollection(false); - - self::assertFalse($configuration->isStrictMode()); - self::assertFalse($configuration->shouldCollectErrors()); - } - - #[Test] - public function itSupportsEmptyStringConfiguration(): void - { - $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); - - self::assertTrue($configuration->shouldTreatEmptyStringAsNull()); - self::assertTrue($configuration->withEmptyStringAsNull(true)->shouldTreatEmptyStringAsNull()); - } - - #[Test] - public function itSupportsExtendedFlags(): void - { - $configuration = MappingConfiguration::lenient() - ->withIgnoreUnknownProperties(true) - ->withTreatNullAsEmptyCollection(true) - ->withDefaultDateFormat('d.m.Y H:i:s') - ->withScalarToObjectCasting(true); - - self::assertTrue($configuration->shouldIgnoreUnknownProperties()); - self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y H:i:s', $configuration->getDefaultDateFormat()); - self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); - } - - #[Test] - public function itDerivesFromContext(): void - { - $context = new MappingContext([], [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, - MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, - MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, - MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', - MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, - ]); - - $configuration = MappingConfiguration::fromContext($context); - - self::assertTrue($configuration->isStrictMode()); - self::assertTrue($configuration->shouldCollectErrors()); - self::assertFalse($configuration->shouldTreatEmptyStringAsNull()); - self::assertTrue($configuration->shouldIgnoreUnknownProperties()); - self::assertTrue($configuration->shouldTreatNullAsEmptyCollection()); - self::assertSame('d.m.Y', $configuration->getDefaultDateFormat()); - self::assertTrue($configuration->shouldAllowScalarToObjectCasting()); - self::assertSame( - [ - MappingContext::OPTION_STRICT_MODE => true, - MappingContext::OPTION_COLLECT_ERRORS => true, - MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL => false, - MappingContext::OPTION_IGNORE_UNKNOWN_PROPERTIES => true, - MappingContext::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION => true, - MappingContext::OPTION_DEFAULT_DATE_FORMAT => 'd.m.Y', - MappingContext::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING => true, - ], - $configuration->toOptions(), - ); - } -} diff --git a/tests/JsonMapper/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php index e53132c..e05c28d 100644 --- a/tests/JsonMapper/JsonMapperErrorHandlingTest.php +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -11,7 +11,7 @@ namespace MagicSunday\Test\JsonMapper; -use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Exception\CollectionMappingException; use MagicSunday\JsonMapper\Exception\MissingPropertyException; use MagicSunday\JsonMapper\Exception\ReadonlyPropertyException; @@ -63,7 +63,7 @@ public function itThrowsOnUnknownPropertiesInStrictMode(): void Person::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -78,7 +78,7 @@ public function itThrowsOnMissingRequiredProperties(): void Person::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -93,7 +93,7 @@ public function itThrowsOnTypeMismatch(): void Base::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -111,7 +111,7 @@ public function itThrowsOnInvalidCollectionPayloads(): void Base::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } @@ -213,7 +213,7 @@ public function itThrowsOnInvalidNestedCollectionEntriesInStrictMode(): void Base::class, null, null, - MappingConfiguration::strict(), + JsonMapperConfiguration::strict(), ); } } diff --git a/tests/JsonMapperTest.php b/tests/JsonMapperTest.php index 4ec3a31..ee78f2b 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -12,8 +12,7 @@ namespace MagicSunday\Test; use DateInterval; -use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; -use MagicSunday\JsonMapper\Configuration\MappingConfiguration; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Exception\UnknownPropertyException; use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use MagicSunday\Test\Classes\Base; @@ -883,7 +882,7 @@ public function mapScalarZeroStringToFalse(): void #[Test] public function mapEmptyStringToNullWhenEnabled(): void { - $configuration = MappingConfiguration::lenient()->withEmptyStringAsNull(true); + $configuration = JsonMapperConfiguration::lenient()->withEmptyStringAsNull(true); $result = $this->getJsonMapper() ->map( @@ -901,7 +900,7 @@ public function mapEmptyStringToNullWhenEnabled(): void #[Test] public function itAppliesConfiguredStrictModeByDefault(): void { - $config = (new JsonMapperConfig())->withStrictMode(true); + $config = (new JsonMapperConfiguration())->withStrictMode(true); $this->expectException(UnknownPropertyException::class); @@ -917,7 +916,7 @@ public function itAppliesConfiguredStrictModeByDefault(): void #[Test] public function itIgnoresUnknownPropertiesWhenConfigured(): void { - $config = (new JsonMapperConfig())->withIgnoreUnknownProperties(true); + $config = (new JsonMapperConfiguration())->withIgnoreUnknownProperties(true); $result = $this->getJsonMapper([], $config) ->mapWithReport( @@ -935,7 +934,7 @@ public function itIgnoresUnknownPropertiesWhenConfigured(): void #[Test] public function itTreatsNullCollectionsAsEmptyWhenConfigured(): void { - $config = (new JsonMapperConfig())->withTreatNullAsEmptyCollection(true); + $config = (new JsonMapperConfiguration())->withTreatNullAsEmptyCollection(true); $result = $this->getJsonMapper([], $config) ->map( @@ -952,7 +951,7 @@ public function itTreatsNullCollectionsAsEmptyWhenConfigured(): void #[Test] public function itUsesDefaultDateFormatFromConfiguration(): void { - $config = (new JsonMapperConfig())->withDefaultDateFormat('d.m.Y H:i:s'); + $config = (new JsonMapperConfiguration())->withDefaultDateFormat('d.m.Y H:i:s'); $result = $this->getJsonMapper([], $config) ->map( @@ -969,7 +968,7 @@ public function itUsesDefaultDateFormatFromConfiguration(): void #[Test] public function itAllowsScalarToObjectCastingWhenConfigured(): void { - $config = (new JsonMapperConfig())->withScalarToObjectCasting(true); + $config = (new JsonMapperConfiguration())->withScalarToObjectCasting(true); $result = $this->getJsonMapper([], $config) ->mapWithReport( diff --git a/tests/TestCase.php b/tests/TestCase.php index 19eac96..47415b1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,7 +14,7 @@ use Closure; use JsonException; use MagicSunday\JsonMapper; -use MagicSunday\JsonMapper\Configuration\JsonMapperConfig; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -34,9 +34,9 @@ class TestCase extends \PHPUnit\Framework\TestCase * Returns an instance of the JsonMapper for testing. * * @param array $classMap - * @param JsonMapperConfig|null $config + * @param JsonMapperConfiguration|null $config */ - protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config = null): JsonMapper + protected function getJsonMapper(array $classMap = [], ?JsonMapperConfiguration $config = null): JsonMapper { $listExtractors = [new ReflectionExtractor()]; $typeExtractors = [new PhpDocExtractor()]; @@ -48,7 +48,7 @@ protected function getJsonMapper(array $classMap = [], ?JsonMapperConfig $config new CamelCasePropertyNameConverter(), $classMap, null, - $config ?? new JsonMapperConfig(), + $config ?? new JsonMapperConfiguration(), ); } From 8ae844d8967decbff7dda3a9302c8d18d7b72552 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 08:14:22 +0100 Subject: [PATCH 21/46] Add robustness tests for JsonMapper edge cases --- tests/Classes/LargeDatasetItem.php | 24 ++++ tests/Classes/LargeDatasetRoot.php | 23 ++++ tests/Classes/RecursiveNode.php | 22 ++++ .../JsonMapperErrorHandlingTest.php | 57 ++++++++++ tests/JsonMapper/JsonMapperRobustnessTest.php | 104 ++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 tests/Classes/LargeDatasetItem.php create mode 100644 tests/Classes/LargeDatasetRoot.php create mode 100644 tests/Classes/RecursiveNode.php create mode 100644 tests/JsonMapper/JsonMapperRobustnessTest.php diff --git a/tests/Classes/LargeDatasetItem.php b/tests/Classes/LargeDatasetItem.php new file mode 100644 index 0000000..1d72038 --- /dev/null +++ b/tests/Classes/LargeDatasetItem.php @@ -0,0 +1,24 @@ +expectException(InvalidTypeException::class); + $this->expectExceptionMessage('Expected argument of type "string", "null" given at property path "name".'); + + $this->getJsonMapper() + ->map( + ['name' => null], + Person::class, + null, + null, + JsonMapperConfiguration::strict(), + ); + } + + #[Test] + public function itReportsInvalidDateTimeValuesInLenientMode(): void + { + $result = $this->getJsonMapper() + ->mapWithReport( + ['createdAt' => 'not-a-date'], + DateTimeHolder::class, + ); + + $errors = $result->getReport()->getErrors(); + + self::assertCount(1, $errors); + self::assertInstanceOf(TypeMismatchException::class, $errors[0]->getException()); + self::assertSame( + 'Type mismatch at $.createdAt: expected DateTimeImmutable, got string.', + $errors[0]->getMessage(), + ); + } + + #[Test] + public function itReportsInvalidEnumValuesInLenientMode(): void + { + $result = $this->getJsonMapper() + ->mapWithReport( + ['status' => 'archived'], + EnumHolder::class, + ); + + $errors = $result->getReport()->getErrors(); + + self::assertCount(1, $errors); + self::assertInstanceOf(TypeMismatchException::class, $errors[0]->getException()); + self::assertSame( + 'Type mismatch at $.status: expected MagicSunday\\Test\\Fixtures\\Enum\\SampleStatus, got string.', + $errors[0]->getMessage(), + ); + } } diff --git a/tests/JsonMapper/JsonMapperRobustnessTest.php b/tests/JsonMapper/JsonMapperRobustnessTest.php new file mode 100644 index 0000000..1d18b2a --- /dev/null +++ b/tests/JsonMapper/JsonMapperRobustnessTest.php @@ -0,0 +1,104 @@ +getJsonMapper()->map( + [ + 'simpleArray' => [], + 'simpleCollection' => [], + ], + Base::class, + ); + + self::assertInstanceOf(Base::class, $result); + self::assertSame([], $result->simpleArray); + self::assertInstanceOf(Collection::class, $result->simpleCollection); + self::assertCount(0, $result->simpleCollection); + } + + #[Test] + public function itMapsDeeplyNestedRecursiveStructures(): void + { + $depth = 8; + $payload = ['name' => 'level-0']; + $cursor = &$payload; + + for ($i = 1; $i < $depth; ++$i) { + $cursor['child'] = ['name' => 'level-' . $i]; + $cursor = &$cursor['child']; + } + + $result = $this->getJsonMapper()->map($payload, RecursiveNode::class); + + self::assertInstanceOf(RecursiveNode::class, $result); + + $node = $result; + for ($i = 0; $i < $depth; ++$i) { + self::assertSame('level-' . $i, $node->name); + + if ($i === $depth - 1) { + self::assertNull($node->child); + + continue; + } + + self::assertInstanceOf(RecursiveNode::class, $node->child); + $node = $node->child; + } + } + + #[Test] + public function itMapsLargeDatasetsWithinReasonableResources(): void + { + $items = []; + for ($i = 0; $i < 500; ++$i) { + $items[] = [ + 'identifier' => $i, + 'label' => 'Item #' . $i, + 'active' => $i % 2 === 0, + ]; + } + + $result = $this->getJsonMapper()->map( + ['items' => $items], + LargeDatasetRoot::class, + ); + + self::assertInstanceOf(LargeDatasetRoot::class, $result); + + /** @var LargeDatasetItem[] $datasetItems */ + $datasetItems = $result->items; + + self::assertCount(500, $datasetItems); + self::assertContainsOnlyInstancesOf(LargeDatasetItem::class, $datasetItems); + self::assertSame('Item #0', $datasetItems[0]->label); + self::assertTrue($datasetItems[0]->active); + self::assertSame(499, $datasetItems[499]->identifier); + self::assertFalse($datasetItems[499]->active); + } +} From 22590c8d1b3551ea8911233ae64fe4a0bc3b0316 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 08:34:12 +0100 Subject: [PATCH 22/46] fix: specify generics for phpstan compliance --- TASKS.md | 53 +++++++++++++++++++ src/JsonMapper.php | 12 +++++ .../CollectionDocBlockTypeResolver.php | 8 +++ .../Collection/CollectionFactory.php | 11 ++++ .../Collection/CollectionFactoryInterface.php | 8 ++- src/JsonMapper/Type/TypeResolver.php | 6 +++ .../BuiltinValueConversionStrategy.php | 9 ++++ .../CollectionValueConversionStrategy.php | 3 ++ .../ObjectTypeConversionGuardTrait.php | 4 ++ .../ObjectValueConversionStrategy.php | 2 + tests/Classes/Base.php | 2 +- tests/Classes/ClassMap/CollectionSource.php | 3 ++ tests/Classes/ClassMap/CollectionTarget.php | 3 ++ 13 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 TASKS.md diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..428c2b6 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,53 @@ +# Follow-up tasks + +## PHPStan maximum-level review (composer ci:test:php:phpstan) + +### MagicSunday\\JsonMapper +- [x] Resolve PHPStan: `Property MagicSunday\\JsonMapper::$collectionFactory with generic interface MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface does not specify its types: TKey, TValue` (`src/JsonMapper.php:101`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::convertUnionValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper.php:497`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::describeUnionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper.php:586`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::unionAllowsNull() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper.php:597`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::getReflectionClass() return type with generic class ReflectionClass does not specify its types: T` (`src/JsonMapper.php:735`). + +### MagicSunday\\JsonMapper\\Collection\\CollectionDocBlockTypeResolver +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionDocBlockTypeResolver::resolve() return type with generic class Symfony\\Component\\TypeInfo\\Type\\CollectionType does not specify its types: T` (`src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php:53`). + +### MagicSunday\\JsonMapper\\Collection\\CollectionFactory +- [x] Resolve PHPStan: `Class MagicSunday\\JsonMapper\\Collection\\CollectionFactory implements generic interface MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface but does not specify its types: TKey, TValue` (`src/JsonMapper/Collection/CollectionFactory.php:35`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionFactory::fromCollectionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\CollectionType but does not specify its types: T` (`src/JsonMapper/Collection/CollectionFactory.php:94`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionFactory::resolveWrappedClass() has parameter $objectType with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Collection/CollectionFactory.php:120`). + +### MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface::fromCollectionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\CollectionType but does not specify its types: T` (`src/JsonMapper/Collection/CollectionFactoryInterface.php:42`). + +### MagicSunday\\JsonMapper\\Type\\TypeResolver +- [x] Resolve PHPStan: `Property MagicSunday\\JsonMapper\\Type\\TypeResolver::$defaultType with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType does not specify its types: T` (`src/JsonMapper/Type/TypeResolver.php:33`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Type\\TypeResolver::normalizeUnionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper/Type/TypeResolver.php:224`). + +### MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy::normalizeValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php:66`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy::guardCompatibility() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php:125`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy::allowsNull() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php:156`). + +### MagicSunday\\JsonMapper\\Value\\Strategy\\CollectionValueConversionStrategy +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\CollectionValueConversionStrategy::__construct() has parameter $collectionFactory with generic interface MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface but does not specify its types: TKey, TValue` (`src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php:26`). + +### MagicSunday\\JsonMapper\\Value\\Strategy\\DateTimeValueConversionStrategy +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\DateTimeValueConversionStrategy::extractObjectType() return type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:27`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\DateTimeValueConversionStrategy::guardNullableValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:43`). + +### MagicSunday\\JsonMapper\\Value\\Strategy\\EnumValueConversionStrategy +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\EnumValueConversionStrategy::extractObjectType() return type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:27`). +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\EnumValueConversionStrategy::guardNullableValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:43`). + +### MagicSunday\\JsonMapper\\Value\\Strategy\\ObjectValueConversionStrategy +- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\ObjectValueConversionStrategy::resolveClassName() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php:73`). + +### MagicSunday\\Test\\Classes\\Base +- [x] Resolve PHPStan: `Property MagicSunday\\Test\\Classes\\Base::$simpleCollection with generic class MagicSunday\\Test\\Classes\\Collection does not specify its types: TKey, TValue` (`tests/Classes/Base.php:54`). + +### MagicSunday\\Test\\Classes\\ClassMap\\CollectionSource +- [x] Resolve PHPStan: `Class MagicSunday\\Test\\Classes\\ClassMap\\CollectionSource extends generic class MagicSunday\\Test\\Classes\\Collection but does not specify its types: TKey, TValue` (`tests/Classes/ClassMap/CollectionSource.php:23`). + +### MagicSunday\\Test\\Classes\\ClassMap\\CollectionTarget +- [x] Resolve PHPStan: `Class MagicSunday\\Test\\Classes\\ClassMap\\CollectionTarget extends generic class ArrayObject but does not specify its types: TKey, TValue` (`tests/Classes/ClassMap/CollectionTarget.php:23`). diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 3f29a33..5a45020 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -98,6 +98,9 @@ class JsonMapper private ValueConverter $valueConverter; + /** + * @var CollectionFactoryInterface + */ private CollectionFactoryInterface $collectionFactory; private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; @@ -493,6 +496,8 @@ private function convertValue(mixed $json, Type $type, MappingContext $context): /** * Converts the value according to the provided union type. + * + * @param UnionType $type */ private function convertUnionValue(mixed $json, UnionType $type, MappingContext $context): mixed { @@ -582,6 +587,8 @@ private function describeType(Type $type): string /** * Returns a textual representation of the union type. + * + * @param UnionType $type */ private function describeUnionType(UnionType $type): string { @@ -594,6 +601,9 @@ private function describeUnionType(UnionType $type): string return implode('|', $parts); } + /** + * @param UnionType $type + */ private function unionAllowsNull(UnionType $type): bool { foreach ($type->getTypes() as $candidate) { @@ -731,6 +741,8 @@ private function getReflectionProperty(string $className, string $propertyName): * Returns the specified reflection class. * * @param class-string $className + * + * @return ReflectionClass|null */ private function getReflectionClass(string $className): ?ReflectionClass { diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php index cb0cbd0..69cb081 100644 --- a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -19,10 +19,16 @@ use phpDocumentor\Reflection\Types\ContextFactory; use ReflectionClass; use Symfony\Component\PropertyInfo\Util\PhpDocTypeHelper; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Resolves collection value types from PHPDoc annotations on collection classes. + * + * @phpstan-type CollectionWrappedType BuiltinType|BuiltinType|ObjectType */ final class CollectionDocBlockTypeResolver { @@ -49,6 +55,8 @@ public function __construct( * Attempts to resolve a {@see CollectionType} from the collection class PHPDoc. * * @param class-string $collectionClassName + * + * @return CollectionType>|null */ public function resolve(string $collectionClassName): ?CollectionType { diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index a5ccfd7..004cd2e 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -18,9 +18,12 @@ use MagicSunday\JsonMapper\Resolver\ClassResolver; use MagicSunday\JsonMapper\Value\ValueConverter; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; use Traversable; use function get_debug_type; @@ -31,6 +34,10 @@ /** * Creates collections and hydrates wrapping collection classes. + * + * @phpstan-type CollectionWrappedType BuiltinType|BuiltinType|ObjectType + * + * @implements CollectionFactoryInterface */ final readonly class CollectionFactory implements CollectionFactoryInterface { @@ -89,6 +96,8 @@ public function mapIterable(mixed $json, Type $valueType, MappingContext $contex /** * Builds a collection based on the specified collection type description. * + * @param CollectionType> $type + * * @return array|object|null */ public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed @@ -113,6 +122,8 @@ public function fromCollectionType(CollectionType $type, mixed $json, MappingCon /** * Resolves the wrapped collection class name. * + * @param ObjectType $objectType + * * @return class-string * * @throws DomainException diff --git a/src/JsonMapper/Collection/CollectionFactoryInterface.php b/src/JsonMapper/Collection/CollectionFactoryInterface.php index bfc3d6b..eee3018 100644 --- a/src/JsonMapper/Collection/CollectionFactoryInterface.php +++ b/src/JsonMapper/Collection/CollectionFactoryInterface.php @@ -13,13 +13,19 @@ use MagicSunday\JsonMapper\Context\MappingContext; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Describes the operations required to materialize collection values. * * @template TKey of array-key * @template TValue + * + * @phpstan-type CollectionWrappedType BuiltinType|BuiltinType|ObjectType */ interface CollectionFactoryInterface { @@ -35,7 +41,7 @@ public function mapIterable(mixed $json, Type $valueType, MappingContext $contex /** * Builds a collection based on the specified collection type description. * - * @param CollectionType $type The collection type metadata extracted from PHPStan/Psalm annotations. + * @param CollectionType> $type The collection type metadata extracted from PHPStan/Psalm annotations. * * @return array|object|null */ diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index a6acbc8..a03fcf1 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -30,6 +30,9 @@ final class TypeResolver { private const string CACHE_KEY_PREFIX = 'jsonmapper.property_type.'; + /** + * @var BuiltinType + */ private BuiltinType $defaultType; public function __construct( @@ -221,6 +224,9 @@ private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool return $resolved; } + /** + * @param UnionType $type + */ private function normalizeUnionType(UnionType $type): Type { $types = []; diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index 4eacbbb..ee4a602 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -63,6 +63,9 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe return $converted; } + /** + * @param BuiltinType $type + */ private function normalizeValue(mixed $value, BuiltinType $type): mixed { if ($value === null) { @@ -122,6 +125,9 @@ private function normalizeValue(mixed $value, BuiltinType $type): mixed return $value; } + /** + * @param BuiltinType $type + */ private function guardCompatibility(mixed $value, BuiltinType $type, MappingContext $context): void { $identifier = $type->getTypeIdentifier(); @@ -153,6 +159,9 @@ private function guardCompatibility(mixed $value, BuiltinType $type, MappingCont } } + /** + * @param BuiltinType $type + */ private function allowsNull(BuiltinType $type): bool { return $type->isNullable(); diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index 5227c83..12d3db5 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -23,6 +23,9 @@ */ final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface { + /** + * @param CollectionFactoryInterface $collectionFactory + */ public function __construct( private CollectionFactoryInterface $collectionFactory, ) { diff --git a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php index 0483ad6..1c9b474 100644 --- a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php +++ b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php @@ -23,6 +23,8 @@ trait ObjectTypeConversionGuardTrait { /** * Returns the provided type when it represents an object with a class name. + * + * @return ObjectType|null */ private function extractObjectType(Type $type): ?ObjectType { @@ -39,6 +41,8 @@ private function extractObjectType(Type $type): ?ObjectType /** * Ensures null values comply with the target object's nullability. + * + * @param ObjectType $type */ private function guardNullableValue(mixed $value, ObjectType $type, MappingContext $context): void { diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 717ff35..51fb6ae 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -68,6 +68,8 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe /** * Resolves the class name from the provided object type. * + * @param ObjectType $type + * * @return class-string */ private function resolveClassName(ObjectType $type): string diff --git a/tests/Classes/Base.php b/tests/Classes/Base.php index bc04379..fedda98 100644 --- a/tests/Classes/Base.php +++ b/tests/Classes/Base.php @@ -49,7 +49,7 @@ class Base /** * A collection of Simple instances. * - * @var Collection|Simple[] + * @var Collection|array */ public $simpleCollection; diff --git a/tests/Classes/ClassMap/CollectionSource.php b/tests/Classes/ClassMap/CollectionSource.php index ff07cf5..5090ec7 100644 --- a/tests/Classes/ClassMap/CollectionSource.php +++ b/tests/Classes/ClassMap/CollectionSource.php @@ -20,6 +20,9 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ +/** + * @extends Collection + */ class CollectionSource extends Collection { } diff --git a/tests/Classes/ClassMap/CollectionTarget.php b/tests/Classes/ClassMap/CollectionTarget.php index a649180..ae057c2 100644 --- a/tests/Classes/ClassMap/CollectionTarget.php +++ b/tests/Classes/ClassMap/CollectionTarget.php @@ -20,6 +20,9 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ +/** + * @extends ArrayObject + */ class CollectionTarget extends ArrayObject { } From dce0ee19faf57a4de709c9080b65dc8a4747346c Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 09:19:51 +0100 Subject: [PATCH 23/46] chore(project): enforce final mapper and expand docs --- CHANGELOG.md | 9 ++ README.md | 109 ++++++++++++++++++ docs/API.md | 86 ++++++++++++++ docs/recipes/custom-name-converter.md | 29 +++++ docs/recipes/mapping-with-enums.md | 39 +++++++ docs/recipes/nested-collections.md | 54 +++++++++ docs/recipes/using-attributes.md | 35 ++++++ src/JsonMapper.php | 16 +-- .../CamelCasePropertyNameConverter.php | 17 +-- 9 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/recipes/custom-name-converter.md create mode 100644 docs/recipes/mapping-with-enums.md create mode 100644 docs/recipes/nested-collections.md create mode 100644 docs/recipes/using-attributes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..fe2e6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +## Unreleased + +### Changed +- Marked `MagicSunday\\JsonMapper\\JsonMapper` as `final` and promoted constructor dependencies to `readonly` properties for consistent visibility. +- Declared `MagicSunday\\JsonMapper\\Converter\\CamelCasePropertyNameConverter` as `final` and immutable. + +### Documentation +- Added a quick start walkthrough and guidance on type converters, error strategies, and performance tuning to the README. +- Published an API reference (`docs/API.md`) and new recipe guides for enums, attributes, nested collections, and custom name converters. diff --git a/README.md b/README.md index ad81123..8520ba3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,70 @@ composer remove magicsunday/jsonmapper ## Usage +### Quick start +A minimal mapping run consists of two parts: a set of DTOs annotated with collection metadata and the mapper bootstrap code. + +```php +namespace App\Dto; + +use ArrayObject; + +final class Comment +{ + public string $message; +} + +/** + * @extends ArrayObject + */ +final class CommentCollection extends ArrayObject +{ +} + +/** + * @extends ArrayObject + */ +final class ArticleCollection extends ArrayObject +{ +} + +final class Article +{ + public string $title; + + /** @var CommentCollection */ + public CommentCollection $comments; +} +``` + +```php +declare(strict_types=1); + +use App\Dto\Article; +use App\Dto\ArticleCollection; +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +$single = json_decode('{"title":"Hello world","comments":[{"message":"First!"}]}', associative: false, flags: JSON_THROW_ON_ERROR); +$list = json_decode('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]', associative: false, flags: JSON_THROW_ON_ERROR); + +$propertyInfo = new PropertyInfoExtractor( + [new ReflectionExtractor()], + [new PhpDocExtractor()] +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); + +$article = $mapper->map($single, Article::class); +$articles = $mapper->map($list, Article::class, ArticleCollection::class); +``` + +The first call produces an `Article` instance with a populated `CommentCollection`; the second call returns an `ArticleCollection` containing `Article` objects. + ### PHP classes In order to guarantee a seamless mapping of a JSON response into PHP classes you should prepare your classes well. Annotate all properties with the requested type. @@ -200,6 +264,51 @@ protected function getJsonMapper(array $classMap = []): \MagicSunday\JsonMapper } ``` +### Type converters and custom class maps +Custom types should implement `MagicSunday\\JsonMapper\\Value\\TypeHandlerInterface` and can be registered once via `JsonMapper::addTypeHandler()`. For lightweight overrides you may still use `addType()` with a closure, but new code should prefer dedicated handler classes. + +Use `JsonMapper::addCustomClassMapEntry()` when the target class depends on runtime data. The resolver receives the decoded JSON payload and may inspect a `MappingContext` when you need additional state. + +```php +$mapper->addCustomClassMapEntry(SdkFoo::class, static function (array $payload): string { + return $payload['type'] === 'bar' ? FooBar::class : FooBaz::class; +}); +``` + +### Error handling strategies +The mapper operates in a lenient mode by default. Switch to strict mapping when every property must be validated: + +```php +use MagicSunday\\JsonMapper\\Configuration\\JsonMapperConfiguration; + +$config = JsonMapperConfiguration::strict() + ->withCollectErrors(true); + +$result = $mapper->mapWithReport($payload, Article::class, configuration: $config); +``` + +For tolerant APIs combine `JsonMapperConfiguration::lenient()` with `->withIgnoreUnknownProperties(true)` or `->withTreatNullAsEmptyCollection(true)` to absorb schema drifts. + +### Performance hints +Type resolution is the most expensive part of a mapping run. Provide a PSR-6 cache pool to the constructor to reuse computed `Type` metadata: + +```php +use Symfony\\Component\\Cache\\Adapter\\ArrayAdapter; + +$cache = new ArrayAdapter(); +$mapper = new JsonMapper($propertyInfo, $propertyAccessor, nameConverter: null, classMap: [], typeCache: $cache); +``` + +Reuse a single `JsonMapper` instance across requests to share the cached metadata and registered handlers. + +## Additional documentation +* [API reference](docs/API.md) +* Recipes + * [Mapping JSON to PHP enums](docs/recipes/mapping-with-enums.md) + * [Using mapper attributes](docs/recipes/using-attributes.md) + * [Mapping nested collections](docs/recipes/nested-collections.md) + * [Using a custom name converter](docs/recipes/custom-name-converter.md) + ## Development ### Testing diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..fe660bf --- /dev/null +++ b/docs/API.md @@ -0,0 +1,86 @@ +# JsonMapper API reference + +This document summarises the public surface of the JsonMapper package. All classes are namespaced under `MagicSunday\\JsonMapper` unless stated otherwise. + +## JsonMapper (final) +The `JsonMapper` class is the main entry point for mapping arbitrary JSON structures to PHP objects. The class is `final`; prefer composition over inheritance. + +### Constructor +``` +__construct( + PropertyInfoExtractorInterface $extractor, + PropertyAccessorInterface $accessor, + ?PropertyNameConverterInterface $nameConverter = null, + array $classMap = [], + ?CacheItemPoolInterface $typeCache = null, + JsonMapperConfiguration $config = new JsonMapperConfiguration(), +) +``` + +* `$classMap` allows overriding resolved target classes. Use `addCustomClassMapEntry()` for runtime registration. +* `$typeCache` enables caching of resolved Symfony `Type` instances. Any PSR-6 cache pool is supported. +* `$config` provides the default configuration that will be cloned for every mapping operation. + +### Methods + +| Method | Description | +| --- | --- | +| `addTypeHandler(TypeHandlerInterface $handler): self` | Registers a reusable conversion strategy for a specific type. | +| `addType(string $type, Closure $closure): self` | Deprecated shortcut for registering closure-based handlers. Prefer `addTypeHandler()`. | +| `addCustomClassMapEntry(string $className, Closure $resolver): self` | Adds or replaces a class map entry. The resolver receives JSON data (and optionally the current `MappingContext`). | +| `map(mixed $json, ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, ?JsonMapperConfiguration $configuration = null): mixed` | Maps the provided JSON payload to the requested class or collection. | +| `mapWithReport(mixed $json, ?string $className = null, ?string $collectionClassName = null, ?JsonMapperConfiguration $configuration = null): MappingResult` | Maps data and returns a `MappingResult` containing both the mapped value and an error report. | + +> `map()` and `mapWithReport()` accept JSON decoded into arrays or objects (`json_decode(..., associative: false)` is recommended). Collections require either an explicit collection class name or collection PHPDoc (`@extends`) metadata. + +## JsonMapperConfiguration (final) +The `JsonMapperConfiguration` class encapsulates mapping options. All configuration methods return a **new** instance; treat instances as immutable value objects. + +### Factory helpers +* `JsonMapperConfiguration::lenient()` – default, tolerant configuration. +* `JsonMapperConfiguration::strict()` – enables strict mode (missing and unknown properties raise `MappingException`). +* `JsonMapperConfiguration::fromArray(array $data)` – rebuilds a configuration from persisted values. +* `JsonMapperConfiguration::fromContext(MappingContext $context)` – reconstructs a configuration for an existing mapping run. + +### Withers +Each `with*` method toggles a single option and returns a clone: + +| Method | Purpose | +| --- | --- | +| `withStrictMode(bool $enabled)` | Enable strict validation. | +| `withCollectErrors(bool $enabled)` | Collect errors instead of failing fast. Required for `mapWithReport()`. | +| `withTreatEmptyStringAsNull(bool $enabled)` | Map empty strings to `null`. | +| `withIgnoreUnknownProperties(bool $enabled)` | Skip unmapped JSON keys. | +| `withTreatNullAsEmptyCollection(bool $enabled)` | Replace `null` collections with their default value. | +| `withDefaultDateFormat(string $format)` | Configure the default `DateTimeInterface` parsing format. | +| `withScalarToObjectCasting(bool $enabled)` | Allow casting scalar values to object types when possible. | + +Use `toOptions()` to feed configuration data into a `MappingContext`, or `toArray()` to persist settings. + +## Property name converters +`CamelCasePropertyNameConverter` implements `PropertyNameConverterInterface` and is declared `final`. Instantiate it when JSON keys use snake case: + +``` +$nameConverter = new CamelCasePropertyNameConverter(); +$mapper = new JsonMapper($extractor, $accessor, $nameConverter); +``` + +## Custom type handlers +Implement `Value\TypeHandlerInterface` to plug in custom conversion logic: + +``` +final class UuidTypeHandler implements TypeHandlerInterface +{ + public function supports(Type $type, mixed $value): bool + { + return $type instanceof ObjectType && $type->getClassName() === Uuid::class; + } + + public function convert(Type $type, mixed $value, MappingContext $context): Uuid + { + return Uuid::fromString((string) $value); + } +} +``` + +Register handlers via `JsonMapper::addTypeHandler()` to make them available for all mappings. diff --git a/docs/recipes/custom-name-converter.md b/docs/recipes/custom-name-converter.md new file mode 100644 index 0000000..dc12c18 --- /dev/null +++ b/docs/recipes/custom-name-converter.md @@ -0,0 +1,29 @@ +# Using a custom name converter + +Property name converters translate JSON keys to PHP property names. JsonMapper provides `CamelCasePropertyNameConverter` out of the box and allows you to supply your own implementation of `PropertyNameConverterInterface`. + +```php +use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; + +final class UpperSnakeCaseConverter implements PropertyNameConverterInterface +{ + public function convert(string $name): string + { + return strtolower(str_replace('_', '', $name)); + } +} +``` + +```php +use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; +use MagicSunday\JsonMapper\JsonMapper; + +$propertyInfo = /* PropertyInfoExtractorInterface */; +$propertyAccessor = /* PropertyAccessorInterface */; +$converter = new CamelCasePropertyNameConverter(); +// or $converter = new UpperSnakeCaseConverter(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor, $converter); +``` + +Name converters are stateless and should be declared `final`. They are applied to every property access during mapping, so keep the implementation idempotent and efficient. diff --git a/docs/recipes/mapping-with-enums.md b/docs/recipes/mapping-with-enums.md new file mode 100644 index 0000000..38376d4 --- /dev/null +++ b/docs/recipes/mapping-with-enums.md @@ -0,0 +1,39 @@ +# Mapping JSON to PHP enums + +JsonMapper can map backed enums transparently when the target property is typed with the enum class. The built-in `EnumValueConversionStrategy` handles the conversion from scalars to enum cases. + +```php +namespace App\Dto; + +enum Status: string +{ + case Draft = 'draft'; + case Published = 'published'; +} + +final class Article +{ + public string $title; + public Status $status; +} +``` + +```php +use App\Dto\Article; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; +use MagicSunday\JsonMapper\JsonMapper; + +$json = json_decode('{ + "title": "Enum mapping", + "status": "published" +}', associative: false, flags: JSON_THROW_ON_ERROR); + +// Create PropertyInfoExtractor and PropertyAccessor instances as shown in the quick start guide. +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); +$article = $mapper->map($json, Article::class); + +assert($article instanceof Article); +assert($article->status === Status::Published); +``` + +The mapper validates enum values. When strict mode is enabled (`JsonMapperConfiguration::strict()`), an invalid enum value results in a `TypeMismatchException`. diff --git a/docs/recipes/nested-collections.md b/docs/recipes/nested-collections.md new file mode 100644 index 0000000..9f4a2b9 --- /dev/null +++ b/docs/recipes/nested-collections.md @@ -0,0 +1,54 @@ +# Mapping nested collections + +Collections of collections require explicit metadata so JsonMapper can determine the element types at every level. + +```php +namespace App\Dto; + +/** + * @extends \ArrayObject + */ +final class TagCollection extends \ArrayObject +{ +} + +/** + * @extends \ArrayObject + */ +final class NestedTagCollection extends \ArrayObject +{ +} + +final class Article +{ + /** @var NestedTagCollection */ + public NestedTagCollection $tags; +} +``` + +```php +use App\Dto\Article; +use App\Dto\NestedTagCollection; +use App\Dto\Tag; +use App\Dto\TagCollection; +use MagicSunday\JsonMapper\JsonMapper; + +$json = json_decode('[ + { + "tags": [ + [{"name": "php"}], + [{"name": "json"}] + ] + } +]', associative: false, flags: JSON_THROW_ON_ERROR); + +// Create PropertyInfoExtractor and PropertyAccessor instances as shown in the quick start guide. +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); +$articles = $mapper->map($json, Article::class, \ArrayObject::class); + +assert($articles instanceof \ArrayObject); +assert($articles[0] instanceof Article); +assert($articles[0]->tags instanceof NestedTagCollection); +``` + +Each custom collection advertises its value type through the `@extends` PHPDoc annotation, allowing the mapper to recurse through nested structures. diff --git a/docs/recipes/using-attributes.md b/docs/recipes/using-attributes.md new file mode 100644 index 0000000..483bc80 --- /dev/null +++ b/docs/recipes/using-attributes.md @@ -0,0 +1,35 @@ +# Using mapper attributes + +JsonMapper ships with attributes that can refine how JSON data is mapped to PHP objects. + +## `ReplaceNullWithDefaultValue` +Use this attribute on properties that should fall back to their default value when the JSON payload explicitly contains `null`. + +```php +use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; + +final class User +{ + #[ReplaceNullWithDefaultValue] + public array $roles = []; +} +``` + +When a payload contains `{ "roles": null }`, the mapper keeps the default empty array. + +## `ReplaceProperty` +Apply this attribute at class level to redirect one or more incoming property names to a different target property. + +```php +use MagicSunday\JsonMapper\Attribute\ReplaceProperty; + +#[ReplaceProperty('fullName', replaces: ['first_name', 'name'])] +final class Contact +{ + public string $fullName; +} +``` + +Both `first_name` and `name` keys will populate the `$fullName` property. Order matters: the first matching alias wins. + +Attributes can be combined with PHPDoc annotations and work alongside the classic DocBlock metadata. diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 5a45020..b810517 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -90,22 +90,22 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -class JsonMapper +final class JsonMapper { - private TypeResolver $typeResolver; + private readonly TypeResolver $typeResolver; - private ClassResolver $classResolver; + private readonly ClassResolver $classResolver; - private ValueConverter $valueConverter; + private readonly ValueConverter $valueConverter; /** * @var CollectionFactoryInterface */ - private CollectionFactoryInterface $collectionFactory; + private readonly CollectionFactoryInterface $collectionFactory; - private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; + private readonly CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; - private CustomTypeRegistry $customTypeRegistry; + private readonly CustomTypeRegistry $customTypeRegistry; /** * @param array $classMap @@ -119,7 +119,7 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - private JsonMapperConfiguration $config = new JsonMapperConfiguration(), + private readonly JsonMapperConfiguration $config = new JsonMapperConfiguration(), ) { $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); diff --git a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php index e57aba3..9a7c589 100644 --- a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php +++ b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php @@ -21,28 +21,15 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -class CamelCasePropertyNameConverter implements PropertyNameConverterInterface +final class CamelCasePropertyNameConverter implements PropertyNameConverterInterface { - /** - * @var Inflector - */ - private Inflector $inflector; + private readonly Inflector $inflector; - /** - * CamelCasePropertyNameConverter constructor. - */ public function __construct() { $this->inflector = InflectorFactory::create()->build(); } - /** - * Convert the specified JSON property name to its PHP property name. - * - * @param string $name - * - * @return string - */ public function convert(string $name): string { return $this->inflector->camelize($name); From 408356e912f02be8308c24c2987d3f471f1eb87c Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 09:23:28 +0100 Subject: [PATCH 24/46] Minor fix --- src/JsonMapper.php | 22 +++++++++---------- .../CamelCasePropertyNameConverter.php | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index b810517..6b0dc78 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -90,22 +90,22 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -final class JsonMapper +final readonly class JsonMapper { - private readonly TypeResolver $typeResolver; + private TypeResolver $typeResolver; - private readonly ClassResolver $classResolver; + private ClassResolver $classResolver; - private readonly ValueConverter $valueConverter; + private ValueConverter $valueConverter; /** * @var CollectionFactoryInterface */ - private readonly CollectionFactoryInterface $collectionFactory; + private CollectionFactoryInterface $collectionFactory; - private readonly CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; + private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; - private readonly CustomTypeRegistry $customTypeRegistry; + private CustomTypeRegistry $customTypeRegistry; /** * @param array $classMap @@ -114,12 +114,12 @@ final class JsonMapper * @phpstan-param array $classMap */ public function __construct( - private readonly PropertyInfoExtractorInterface $extractor, - private readonly PropertyAccessorInterface $accessor, - private readonly ?PropertyNameConverterInterface $nameConverter = null, + private PropertyInfoExtractorInterface $extractor, + private PropertyAccessorInterface $accessor, + private ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - private readonly JsonMapperConfiguration $config = new JsonMapperConfiguration(), + private JsonMapperConfiguration $config = new JsonMapperConfiguration(), ) { $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); diff --git a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php index 9a7c589..38ba03a 100644 --- a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php +++ b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php @@ -21,9 +21,9 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -final class CamelCasePropertyNameConverter implements PropertyNameConverterInterface +final readonly class CamelCasePropertyNameConverter implements PropertyNameConverterInterface { - private readonly Inflector $inflector; + private Inflector $inflector; public function __construct() { From 79ba1e115f0c0343c5169b12c2afccb6c1fd1b1c Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 09:45:17 +0100 Subject: [PATCH 25/46] Minor fixes --- src/JsonMapper.php | 21 ++++++++++++------- .../CollectionDocBlockTypeResolver.php | 6 ++++-- .../Collection/CollectionFactory.php | 4 ++-- src/JsonMapper/Report/MappingReport.php | 2 ++ src/JsonMapper/Resolver/ClassResolver.php | 7 +++---- src/JsonMapper/Type/TypeResolver.php | 4 +++- .../Strategy/NullValueConversionStrategy.php | 2 +- src/JsonMapper/Value/ValueConverter.php | 5 ++++- 8 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 6b0dc78..e35453c 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -108,10 +108,12 @@ private CustomTypeRegistry $customTypeRegistry; /** + * @param PropertyInfoExtractorInterface $extractor + * @param PropertyAccessorInterface $accessor + * @param PropertyNameConverterInterface|null $nameConverter * @param array $classMap * @param CacheItemPoolInterface|null $typeCache - * - * @phpstan-param array $classMap + * @param JsonMapperConfiguration $config */ public function __construct( private PropertyInfoExtractorInterface $extractor, @@ -204,7 +206,7 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso * @param MappingContext|null $context * @param JsonMapperConfiguration|null $configuration * - * @throws InvalidArgumentException + * @return mixed */ public function map( mixed $json, @@ -216,10 +218,10 @@ public function map( if (!$context instanceof MappingContext) { $configuration ??= $this->createDefaultConfiguration(); $context = new MappingContext($json, $configuration->toOptions()); - } elseif (!$configuration instanceof JsonMapperConfiguration) { - $configuration = JsonMapperConfiguration::fromContext($context); - } else { + } elseif ($configuration instanceof JsonMapperConfiguration) { $context->replaceOptions($configuration->toOptions()); + } else { + $configuration = JsonMapperConfiguration::fromContext($context); } $resolvedClassName = $className === null @@ -387,6 +389,8 @@ public function map( * @param class-string|null $className * @param class-string|null $collectionClassName * @param JsonMapperConfiguration|null $configuration + * + * @return MappingResult */ public function mapWithReport( mixed $json, @@ -508,7 +512,7 @@ private function convertUnionValue(mixed $json, UnionType $type, MappingContext $lastException = null; foreach ($type->getTypes() as $candidate) { - if ($this->isNullType($candidate) && $json !== null) { + if (($json !== null) && $this->isNullType($candidate)) { continue; } @@ -624,6 +628,9 @@ private function isNullType(Type $type): bool * Creates an instance of the given class name. * * @param string $className + * @param mixed ...$constructorArguments + * + * @return object */ private function makeInstance(string $className, mixed ...$constructorArguments): object { diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php index 69cb081..3850226 100644 --- a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -25,6 +25,8 @@ use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\TypeIdentifier; +use function sprintf; + /** * Resolves collection value types from PHPDoc annotations on collection classes. * @@ -36,8 +38,8 @@ final class CollectionDocBlockTypeResolver public function __construct( ?DocBlockFactoryInterface $docBlockFactory = null, - private ContextFactory $contextFactory = new ContextFactory(), - private PhpDocTypeHelper $phpDocTypeHelper = new PhpDocTypeHelper(), + private readonly ContextFactory $contextFactory = new ContextFactory(), + private readonly PhpDocTypeHelper $phpDocTypeHelper = new PhpDocTypeHelper(), ) { if (!class_exists(DocBlockFactory::class)) { throw new LogicException( diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index 004cd2e..4680abb 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -98,9 +98,9 @@ public function mapIterable(mixed $json, Type $valueType, MappingContext $contex * * @param CollectionType> $type * - * @return array|object|null + * @return object|array|null */ - public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): array|object|null { $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php index 886a41e..9e6c55d 100644 --- a/src/JsonMapper/Report/MappingReport.php +++ b/src/JsonMapper/Report/MappingReport.php @@ -13,6 +13,8 @@ use MagicSunday\JsonMapper\Context\MappingError; +use function count; + /** * Represents the result of collecting mapping errors. */ diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index c5d4c18..c19bf7f 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -63,12 +63,11 @@ public function add(string $className, Closure $resolver): void /** * Resolves the class name for the provided JSON payload. * - * @param class-string $className - * @param mixed $json + * @param class-string $className + * @param mixed $json + * @param MappingContext $context * * @return class-string - * - * @throws DomainException */ public function resolve(string $className, mixed $json, MappingContext $context): string { diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index a03fcf1..f6eca10 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -23,6 +23,8 @@ use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; +use function count; + /** * Resolves property types using Symfony's PropertyInfo component. */ @@ -141,7 +143,7 @@ private function storeCachedType(string $className, string $propertyName, Type $ */ private function buildCacheKey(string $className, string $propertyName): string { - return self::CACHE_KEY_PREFIX . strtr($className, '\\', '_') . '.' . $propertyName; + return self::CACHE_KEY_PREFIX . str_replace('\\', '_', $className) . '.' . $propertyName; } /** diff --git a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php index 246ae73..72a3a68 100644 --- a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php @@ -24,7 +24,7 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo return $value === null; } - public function convert(mixed $value, Type $type, MappingContext $context): mixed + public function convert(mixed $value, Type $type, MappingContext $context): null { return null; } diff --git a/src/JsonMapper/Value/ValueConverter.php b/src/JsonMapper/Value/ValueConverter.php index da19c09..b9c183e 100644 --- a/src/JsonMapper/Value/ValueConverter.php +++ b/src/JsonMapper/Value/ValueConverter.php @@ -16,6 +16,8 @@ use MagicSunday\JsonMapper\Value\Strategy\ValueConversionStrategyInterface; use Symfony\Component\TypeInfo\Type; +use function sprintf; + /** * Converts JSON values according to the registered strategies. */ @@ -45,6 +47,7 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe } } - throw new LogicException(sprintf('No conversion strategy available for type %s.', $type::class)); + throw new LogicException( + sprintf('No conversion strategy available for type %s.', $type::class)); } } From b8797bffcc1e0598831c0436d8efedc189760080 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 09:57:34 +0100 Subject: [PATCH 26/46] Remove obsolete stuff --- TASKS.md | 53 ----------------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 TASKS.md diff --git a/TASKS.md b/TASKS.md deleted file mode 100644 index 428c2b6..0000000 --- a/TASKS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Follow-up tasks - -## PHPStan maximum-level review (composer ci:test:php:phpstan) - -### MagicSunday\\JsonMapper -- [x] Resolve PHPStan: `Property MagicSunday\\JsonMapper::$collectionFactory with generic interface MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface does not specify its types: TKey, TValue` (`src/JsonMapper.php:101`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::convertUnionValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper.php:497`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::describeUnionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper.php:586`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::unionAllowsNull() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper.php:597`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper::getReflectionClass() return type with generic class ReflectionClass does not specify its types: T` (`src/JsonMapper.php:735`). - -### MagicSunday\\JsonMapper\\Collection\\CollectionDocBlockTypeResolver -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionDocBlockTypeResolver::resolve() return type with generic class Symfony\\Component\\TypeInfo\\Type\\CollectionType does not specify its types: T` (`src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php:53`). - -### MagicSunday\\JsonMapper\\Collection\\CollectionFactory -- [x] Resolve PHPStan: `Class MagicSunday\\JsonMapper\\Collection\\CollectionFactory implements generic interface MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface but does not specify its types: TKey, TValue` (`src/JsonMapper/Collection/CollectionFactory.php:35`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionFactory::fromCollectionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\CollectionType but does not specify its types: T` (`src/JsonMapper/Collection/CollectionFactory.php:94`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionFactory::resolveWrappedClass() has parameter $objectType with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Collection/CollectionFactory.php:120`). - -### MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface::fromCollectionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\CollectionType but does not specify its types: T` (`src/JsonMapper/Collection/CollectionFactoryInterface.php:42`). - -### MagicSunday\\JsonMapper\\Type\\TypeResolver -- [x] Resolve PHPStan: `Property MagicSunday\\JsonMapper\\Type\\TypeResolver::$defaultType with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType does not specify its types: T` (`src/JsonMapper/Type/TypeResolver.php:33`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Type\\TypeResolver::normalizeUnionType() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\UnionType but does not specify its types: T` (`src/JsonMapper/Type/TypeResolver.php:224`). - -### MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy::normalizeValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php:66`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy::guardCompatibility() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php:125`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\BuiltinValueConversionStrategy::allowsNull() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\BuiltinType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php:156`). - -### MagicSunday\\JsonMapper\\Value\\Strategy\\CollectionValueConversionStrategy -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\CollectionValueConversionStrategy::__construct() has parameter $collectionFactory with generic interface MagicSunday\\JsonMapper\\Collection\\CollectionFactoryInterface but does not specify its types: TKey, TValue` (`src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php:26`). - -### MagicSunday\\JsonMapper\\Value\\Strategy\\DateTimeValueConversionStrategy -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\DateTimeValueConversionStrategy::extractObjectType() return type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:27`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\DateTimeValueConversionStrategy::guardNullableValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:43`). - -### MagicSunday\\JsonMapper\\Value\\Strategy\\EnumValueConversionStrategy -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\EnumValueConversionStrategy::extractObjectType() return type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:27`). -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\EnumValueConversionStrategy::guardNullableValue() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php:43`). - -### MagicSunday\\JsonMapper\\Value\\Strategy\\ObjectValueConversionStrategy -- [x] Resolve PHPStan: `Method MagicSunday\\JsonMapper\\Value\\Strategy\\ObjectValueConversionStrategy::resolveClassName() has parameter $type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types: T` (`src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php:73`). - -### MagicSunday\\Test\\Classes\\Base -- [x] Resolve PHPStan: `Property MagicSunday\\Test\\Classes\\Base::$simpleCollection with generic class MagicSunday\\Test\\Classes\\Collection does not specify its types: TKey, TValue` (`tests/Classes/Base.php:54`). - -### MagicSunday\\Test\\Classes\\ClassMap\\CollectionSource -- [x] Resolve PHPStan: `Class MagicSunday\\Test\\Classes\\ClassMap\\CollectionSource extends generic class MagicSunday\\Test\\Classes\\Collection but does not specify its types: TKey, TValue` (`tests/Classes/ClassMap/CollectionSource.php:23`). - -### MagicSunday\\Test\\Classes\\ClassMap\\CollectionTarget -- [x] Resolve PHPStan: `Class MagicSunday\\Test\\Classes\\ClassMap\\CollectionTarget extends generic class ArrayObject but does not specify its types: TKey, TValue` (`tests/Classes/ClassMap/CollectionTarget.php:23`). From d926ae851059f3d93eb599966daba498ca344266 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:31:42 +0100 Subject: [PATCH 27/46] docs(mapper): clarify mapping phpdoc and flow --- src/JsonMapper.php | 57 ++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index e35453c..f7360fe 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -108,12 +108,14 @@ private CustomTypeRegistry $customTypeRegistry; /** - * @param PropertyInfoExtractorInterface $extractor - * @param PropertyAccessorInterface $accessor - * @param PropertyNameConverterInterface|null $nameConverter - * @param array $classMap - * @param CacheItemPoolInterface|null $typeCache - * @param JsonMapperConfiguration $config + * Creates a mapper that converts JSON data into PHP objects using the configured Symfony services. + * + * @param PropertyInfoExtractorInterface $extractor Extractor that provides type information for mapped properties. + * @param PropertyAccessorInterface $accessor Property accessor used to write values onto target objects. + * @param PropertyNameConverterInterface|null $nameConverter Optional converter to normalise incoming property names. + * @param array $classMap Map of base classes to resolvers that determine the concrete class to instantiate. + * @param CacheItemPoolInterface|null $typeCache Optional cache for resolved type information. + * @param JsonMapperConfiguration $config Default mapper configuration cloned for new mapping contexts. */ public function __construct( private PropertyInfoExtractorInterface $extractor, @@ -161,6 +163,10 @@ function (mixed $value, string $resolvedClass, MappingContext $context): mixed { /** * Registers a custom type handler. + * + * @param TypeHandlerInterface $handler Type handler implementation to register with the mapper. + * + * @return JsonMapper Returns the mapper instance for fluent configuration. */ public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper { @@ -172,7 +178,12 @@ public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper /** * Registers a custom type using a closure-based handler. * + * @param string $type Name of the custom type alias handled by the closure. + * @param Closure $closure Closure that converts the incoming value to the target type. + * * @deprecated Use addTypeHandler() with a TypeHandlerInterface implementation instead. + * + * @return JsonMapper Returns the mapper instance for fluent configuration. */ public function addType(string $type, Closure $closure): JsonMapper { @@ -184,11 +195,13 @@ public function addType(string $type, Closure $closure): JsonMapper /** * Add a custom class map entry. * - * @param class-string $className - * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure + * @param class-string $className Fully qualified class name that should be resolved dynamically. + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure Closure that returns the concrete class to instantiate for the provided value. * * @phpstan-param class-string $className * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure + * + * @return JsonMapper Returns the mapper instance for fluent configuration. */ public function addCustomClassMapEntry(string $className, Closure $closure): JsonMapper { @@ -200,13 +213,13 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName - * @param MappingContext|null $context - * @param JsonMapperConfiguration|null $configuration + * @param mixed $json Source data to map into PHP objects. + * @param class-string|null $className Fully qualified class name that should be instantiated for mapped objects. + * @param class-string|null $collectionClassName Collection class that should wrap the mapped objects when required. + * @param MappingContext|null $context Optional mapping context reused across nested mappings. + * @param JsonMapperConfiguration|null $configuration Optional configuration that overrides the default mapper settings. * - * @return mixed + * @return mixed The mapped PHP value or collection produced from the given JSON. */ public function map( mixed $json, @@ -224,6 +237,7 @@ public function map( $configuration = JsonMapperConfiguration::fromContext($context); } + // Resolve the target class and optional collection from the configured resolvers. $resolvedClassName = $className === null ? null : $this->classResolver->resolve($className, $json, $context); @@ -237,6 +251,7 @@ public function map( /** @var Type|null $collectionValueType */ $collectionValueType = null; + // Determine the element type when the mapping targets a collection. if ($resolvedCollectionClassName !== null) { if ($resolvedClassName !== null) { $collectionValueType = new ObjectType($resolvedClassName); @@ -264,6 +279,7 @@ public function map( $isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType !== null; + // Map into a standalone collection when the element class is derived from the collection definition. if ($isGenericCollectionMapping) { if ($resolvedCollectionClassName === null) { throw new InvalidArgumentException('A collection class name must be provided when mapping without an element class.'); @@ -282,6 +298,7 @@ public function map( return $this->makeInstance($resolvedClassName); } + // Map array or object sources into the configured collection type when requested. if ( ($resolvedCollectionClassName !== null) && $this->isIterableWithArraysOrObjects($json) @@ -291,6 +308,7 @@ public function map( return $this->makeInstance($resolvedCollectionClassName, $collection); } + // Handle sequential arrays by mapping them into a native collection of resolved objects. if ( $this->isIterableWithArraysOrObjects($json) && $this->isNumericIndexArray($json) @@ -305,6 +323,7 @@ public function map( $replacePropertyMap = $this->buildReplacePropertyMap($resolvedClassName); $mappedProperties = []; + // Iterate over the source data and map each property onto the target entity. foreach ($source as $propertyName => $propertyValue) { $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); $pathSegment = is_string($normalizedProperty) ? $normalizedProperty : (string) $propertyName; @@ -385,12 +404,12 @@ public function map( /** * Maps the JSON structure and returns a detailed mapping report. * - * @param mixed $json - * @param class-string|null $className - * @param class-string|null $collectionClassName - * @param JsonMapperConfiguration|null $configuration + * @param mixed $json Source data to map into PHP objects. + * @param class-string|null $className Fully qualified class name that should be instantiated for mapped objects. + * @param class-string|null $collectionClassName Collection class that should wrap the mapped objects when required. + * @param JsonMapperConfiguration|null $configuration Optional configuration that overrides the default mapper settings. * - * @return MappingResult + * @return MappingResult Mapping result containing the mapped value and a detailed report. */ public function mapWithReport( mixed $json, From e4b82300d8757fb979c1b035964379a572eadad9 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:32:10 +0100 Subject: [PATCH 28/46] docs(value): expand phpdoc for conversion components --- src/JsonMapper/Value/CustomTypeRegistry.php | 20 +++++++- .../BuiltinValueConversionStrategy.php | 47 +++++++++++++++++-- .../CollectionValueConversionStrategy.php | 22 ++++++++- .../CustomTypeValueConversionStrategy.php | 23 +++++++++ .../DateTimeValueConversionStrategy.php | 18 +++++++ .../Strategy/EnumValueConversionStrategy.php | 18 +++++++ .../Strategy/NullValueConversionStrategy.php | 18 +++++++ .../ObjectTypeConversionGuardTrait.php | 17 +++++-- .../ObjectValueConversionStrategy.php | 27 +++++++++-- .../PassthroughValueConversionStrategy.php | 18 +++++++ .../ValueConversionStrategyInterface.php | 18 +++++++ src/JsonMapper/Value/ValueConverter.php | 13 ++++- 12 files changed, 247 insertions(+), 12 deletions(-) diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index 7b99dd8..a8aaff4 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -30,7 +30,10 @@ final class CustomTypeRegistry /** * Registers the converter for the provided class name. * - * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter + * @param string $className Fully-qualified class name handled by the converter. + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter Callback responsible for creating the destination value. + * + * @return void */ public function register(string $className, callable $converter): void { @@ -39,6 +42,10 @@ public function register(string $className, callable $converter): void /** * Registers a custom type handler. + * + * @param TypeHandlerInterface $handler Handler performing support checks and conversion for a particular type. + * + * @return void */ public function registerHandler(TypeHandlerInterface $handler): void { @@ -47,6 +54,11 @@ public function registerHandler(TypeHandlerInterface $handler): void /** * Returns TRUE if a handler for the type exists. + * + * @param Type $type Type information describing the target property. + * @param mixed $value JSON value that should be converted. + * + * @return bool TRUE when at least one registered handler supports the value. */ public function supports(Type $type, mixed $value): bool { @@ -61,6 +73,12 @@ public function supports(Type $type, mixed $value): bool /** * Executes the converter for the class. + * + * @param Type $type Type information describing the target property. + * @param mixed $value JSON value that should be converted. + * @param MappingContext $context Mapping context providing runtime configuration and state. + * + * @return mixed Converted value returned by the first supporting handler. */ public function convert(Type $type, mixed $value, MappingContext $context): mixed { diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index ee4a602..2556fcb 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -40,11 +40,29 @@ */ final class BuiltinValueConversionStrategy implements ValueConversionStrategyInterface { + /** + * Determines whether the provided type represents a builtin PHP value. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the target type is a builtin PHP type. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { return $type instanceof BuiltinType; } + /** + * Converts the provided value to the builtin type defined by the metadata. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Value cast to the requested builtin type when possible. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { assert($type instanceof BuiltinType); @@ -64,7 +82,12 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe } /** - * @param BuiltinType $type + * Normalizes common scalar representations before the conversion happens. + * + * @param mixed $value Raw value coming from the input payload. + * @param BuiltinType $type Type metadata describing the target property. + * + * @return mixed Normalized value that is compatible with the builtin type conversion. */ private function normalizeValue(mixed $value, BuiltinType $type): mixed { @@ -126,7 +149,13 @@ private function normalizeValue(mixed $value, BuiltinType $type): mixed } /** - * @param BuiltinType $type + * Validates that the value matches the builtin type or records a mismatch. + * + * @param mixed $value Normalized value used during conversion. + * @param BuiltinType $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return void */ private function guardCompatibility(mixed $value, BuiltinType $type, MappingContext $context): void { @@ -160,13 +189,25 @@ private function guardCompatibility(mixed $value, BuiltinType $type, MappingCont } /** - * @param BuiltinType $type + * Determines whether the builtin type allows null values. + * + * @param BuiltinType $type Type metadata describing the target property. + * + * @return bool TRUE when the builtin type can be null. */ private function allowsNull(BuiltinType $type): bool { return $type->isNullable(); } + /** + * Checks whether the value matches the builtin type identifier. + * + * @param mixed $value Normalized value used during conversion. + * @param TypeIdentifier $identifier Identifier of the builtin type to check against. + * + * @return bool TRUE when the value matches the identifier requirements. + */ private function isCompatibleValue(mixed $value, TypeIdentifier $identifier): bool { return match ($identifier->value) { diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index 12d3db5..f3f7e05 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -24,18 +24,38 @@ final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface { /** - * @param CollectionFactoryInterface $collectionFactory + * Creates the strategy with the provided collection factory. + * + * @param CollectionFactoryInterface $collectionFactory Factory responsible for instantiating collections during conversion. */ public function __construct( private CollectionFactoryInterface $collectionFactory, ) { } + /** + * Determines whether the supplied type represents a collection. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the target type is a collection type. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { return $type instanceof CollectionType; } + /** + * Converts the JSON value into a collection instance. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Collection created by the factory based on the type metadata. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { assert($type instanceof CollectionType); diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php index c408a85..5093d59 100644 --- a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -20,16 +20,39 @@ */ final readonly class CustomTypeValueConversionStrategy implements ValueConversionStrategyInterface { + /** + * Creates the strategy backed by the custom type registry. + * + * @param CustomTypeRegistry $registry Registry containing the custom handlers. + */ public function __construct( private CustomTypeRegistry $registry, ) { } + /** + * Determines whether the registry can handle the provided type. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the registry has a matching custom handler. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { return $this->registry->supports($type, $value); } + /** + * Converts the value using the registered handler. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Value produced by the registered custom handler. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { return $this->registry->convert($type, $value, $context); diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php index ac60f44..8c54049 100644 --- a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -32,6 +32,15 @@ final class DateTimeValueConversionStrategy implements ValueConversionStrategyIn { use ObjectTypeConversionGuardTrait; + /** + * Determines whether the requested type is a supported date or interval class. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the type represents a supported date/time object. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { $objectType = $this->extractObjectType($type); @@ -45,6 +54,15 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo return is_a($className, DateTimeImmutable::class, true) || is_a($className, DateInterval::class, true); } + /** + * Converts ISO-8601 strings and timestamps into the desired date/time object. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Instance of the configured date/time class. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { return $this->convertObjectValue( diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php index 1672d55..db4b1c6 100644 --- a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -31,6 +31,15 @@ final class EnumValueConversionStrategy implements ValueConversionStrategyInterf { use ObjectTypeConversionGuardTrait; + /** + * Determines whether the provided type is a backed enum. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the target type resolves to a backed enum. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { $objectType = $this->extractObjectType($type); @@ -48,6 +57,15 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo return is_a($className, BackedEnum::class, true); } + /** + * Converts the JSON scalar into the matching enum case. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Backed enum instance returned by the case factory method. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { return $this->convertObjectValue( diff --git a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php index 72a3a68..535a4d0 100644 --- a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php @@ -19,11 +19,29 @@ */ final class NullValueConversionStrategy implements ValueConversionStrategyInterface { + /** + * Determines whether the incoming value represents a null assignment. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the value is exactly null. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { return $value === null; } + /** + * Returns null to preserve the absence of a value. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return null Always returns null for supported values. + */ public function convert(mixed $value, Type $type, MappingContext $context): null { return null; diff --git a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php index 1c9b474..4633f0b 100644 --- a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php +++ b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php @@ -24,7 +24,9 @@ trait ObjectTypeConversionGuardTrait /** * Returns the provided type when it represents an object with a class name. * - * @return ObjectType|null + * @param Type $type Type metadata describing the target property. + * + * @return ObjectType|null Object type when the metadata targets a concrete class; otherwise null. */ private function extractObjectType(Type $type): ?ObjectType { @@ -42,7 +44,11 @@ private function extractObjectType(Type $type): ?ObjectType /** * Ensures null values comply with the target object's nullability. * - * @param ObjectType $type + * @param mixed $value Raw value coming from the input payload. + * @param ObjectType $type Object type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return void */ private function guardNullableValue(mixed $value, ObjectType $type, MappingContext $context): void { @@ -60,7 +66,12 @@ private function guardNullableValue(mixed $value, ObjectType $type, MappingConte /** * Executes the provided converter when a valid object type is available. * - * @param callable(string, mixed): mixed $converter + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * @param mixed $value Raw value coming from the input payload. + * @param callable(string, mixed): mixed $converter Callback that performs the actual conversion when a class-string is available. + * + * @return mixed Result from the converter or the original value when no object type was detected. */ private function convertObjectValue(Type $type, MappingContext $context, mixed $value, callable $converter): mixed { diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 51fb6ae..414e1cd 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -29,7 +29,10 @@ final readonly class ObjectValueConversionStrategy implements ValueConversionStrategyInterface { /** - * @param Closure(mixed, class-string, MappingContext):mixed $mapper + * Creates the strategy with the class resolver and mapper callback. + * + * @param ClassResolver $classResolver Resolver used to select the concrete class to instantiate. + * @param Closure(mixed, class-string, MappingContext):mixed $mapper Callback responsible for mapping values into objects. */ public function __construct( private ClassResolver $classResolver, @@ -37,11 +40,29 @@ public function __construct( ) { } + /** + * Determines whether the metadata describes an object type. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the target type represents an object. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { return $type instanceof ObjectType; } + /** + * Delegates conversion to the mapper for the resolved class. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Value returned by the mapper callback. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { if (!($type instanceof ObjectType)) { @@ -68,9 +89,9 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe /** * Resolves the class name from the provided object type. * - * @param ObjectType $type + * @param ObjectType $type Object type metadata describing the target property. * - * @return class-string + * @return class-string Concrete class name extracted from the metadata. */ private function resolveClassName(ObjectType $type): string { diff --git a/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php index 74aba11..f5ddf06 100644 --- a/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php @@ -19,11 +19,29 @@ */ final class PassthroughValueConversionStrategy implements ValueConversionStrategyInterface { + /** + * Always supports conversion and acts as the terminal strategy. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool Always TRUE so the strategy can act as the final fallback. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool { return true; } + /** + * Returns the original value without modification. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Unmodified value passed through from the input. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { return $value; diff --git a/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php b/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php index ba2b471..f37020d 100644 --- a/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php +++ b/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php @@ -19,7 +19,25 @@ */ interface ValueConversionStrategyInterface { + /** + * Determines whether the strategy can convert the provided value for the requested type. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return bool TRUE when the strategy should perform the conversion. + */ public function supports(mixed $value, Type $type, MappingContext $context): bool; + /** + * Converts the value into a representation compatible with the requested type. + * + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Result of the conversion when the strategy supports the value. + */ public function convert(mixed $value, Type $type, MappingContext $context): mixed; } diff --git a/src/JsonMapper/Value/ValueConverter.php b/src/JsonMapper/Value/ValueConverter.php index b9c183e..0f68da6 100644 --- a/src/JsonMapper/Value/ValueConverter.php +++ b/src/JsonMapper/Value/ValueConverter.php @@ -30,6 +30,10 @@ final class ValueConverter /** * Registers the strategy at the end of the chain. + * + * @param ValueConversionStrategyInterface $strategy Strategy executed when it supports the provided value. + * + * @return void */ public function addStrategy(ValueConversionStrategyInterface $strategy): void { @@ -38,6 +42,12 @@ public function addStrategy(ValueConversionStrategyInterface $strategy): void /** * Converts the value using the first matching strategy. + * + * @param mixed $value Raw JSON value that needs to be converted. + * @param Type $type Target type metadata that should be satisfied by the conversion result. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * + * @return mixed Result from the first strategy that declares support for the value. */ public function convert(mixed $value, Type $type, MappingContext $context): mixed { @@ -48,6 +58,7 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe } throw new LogicException( - sprintf('No conversion strategy available for type %s.', $type::class)); + sprintf('No conversion strategy available for type %s.', $type::class) + ); } } From 7ca9b6cfd9b3a02979655516bdcd70e17f8b2745 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:32:37 +0100 Subject: [PATCH 29/46] docs(context): expand mapping phpdoc coverage --- .../Configuration/JsonMapperConfiguration.php | 62 ++++++++++++++- src/JsonMapper/Context/MappingContext.php | 79 +++++++++++++++++-- src/JsonMapper/Context/MappingError.php | 20 +++++ 3 files changed, 152 insertions(+), 9 deletions(-) diff --git a/src/JsonMapper/Configuration/JsonMapperConfiguration.php b/src/JsonMapper/Configuration/JsonMapperConfiguration.php index e06578f..a240670 100644 --- a/src/JsonMapper/Configuration/JsonMapperConfiguration.php +++ b/src/JsonMapper/Configuration/JsonMapperConfiguration.php @@ -23,6 +23,14 @@ final class JsonMapperConfiguration { /** * Creates a new configuration instance with optional overrides. + * + * @param bool $strictMode Whether unknown/missing properties should trigger errors + * @param bool $collectErrors Whether encountered mapping errors should be collected + * @param bool $emptyStringIsNull Whether empty strings are converted to null + * @param bool $ignoreUnknownProperties Whether properties missing in the destination type are ignored + * @param bool $treatNullAsEmptyCollection Whether null collections are replaced with empty collections + * @param string $defaultDateFormat Default `DateTimeInterface` format used for serialization/deserialization + * @param bool $allowScalarToObjectCasting Whether scalars can be coerced into objects when supported */ public function __construct( private bool $strictMode = false, @@ -37,6 +45,8 @@ public function __construct( /** * Returns a lenient configuration with default settings. + * + * @return self Configuration tuned for permissive mappings */ public static function lenient(): self { @@ -45,6 +55,8 @@ public static function lenient(): self /** * Returns a strict configuration that reports unknown and missing properties. + * + * @return self Configuration tuned for strict validation */ public static function strict(): self { @@ -55,6 +67,8 @@ public static function strict(): self * Restores a configuration instance from the provided array. * * @param array $data Configuration values indexed by property name + * + * @return self Configuration populated with the provided overrides */ public static function fromArray(array $data): self { @@ -77,6 +91,8 @@ public static function fromArray(array $data): self /** * Restores a configuration instance based on the provided mapping context. + * + * @return self Configuration aligned with the supplied context options */ public static function fromContext(MappingContext $context): self { @@ -94,7 +110,7 @@ public static function fromContext(MappingContext $context): self /** * Serializes the configuration into an array representation. * - * @return array + * @return array Scalar configuration flags indexed by option name */ public function toArray(): array { @@ -112,7 +128,7 @@ public function toArray(): array /** * Converts the configuration to mapping context options. * - * @return array + * @return array Mapping context option bag compatible with {@see MappingContext} */ public function toOptions(): array { @@ -129,6 +145,8 @@ public function toOptions(): array /** * Indicates whether strict mode is enabled. + * + * @return bool True when unknown or missing properties are treated as failures */ public function isStrictMode(): bool { @@ -137,6 +155,8 @@ public function isStrictMode(): bool /** * Indicates whether errors should be collected during mapping. + * + * @return bool True when mapper should aggregate errors instead of failing fast */ public function shouldCollectErrors(): bool { @@ -145,6 +165,8 @@ public function shouldCollectErrors(): bool /** * Indicates whether empty strings should be treated as null values. + * + * @return bool True when empty string values are mapped to null */ public function shouldTreatEmptyStringAsNull(): bool { @@ -153,6 +175,8 @@ public function shouldTreatEmptyStringAsNull(): bool /** * Indicates whether unknown properties should be ignored. + * + * @return bool True when incoming properties without a target counterpart are skipped */ public function shouldIgnoreUnknownProperties(): bool { @@ -161,6 +185,8 @@ public function shouldIgnoreUnknownProperties(): bool /** * Indicates whether null collections should be converted to empty collections. + * + * @return bool True when null collection values are normalised to empty collections */ public function shouldTreatNullAsEmptyCollection(): bool { @@ -169,6 +195,8 @@ public function shouldTreatNullAsEmptyCollection(): bool /** * Returns the default date format used for date conversions. + * + * @return string Date format string compatible with {@see DateTimeInterface::format()} */ public function getDefaultDateFormat(): string { @@ -177,6 +205,8 @@ public function getDefaultDateFormat(): string /** * Indicates whether scalar values may be cast to objects. + * + * @return bool True when scalar-to-object coercion should be attempted */ public function shouldAllowScalarToObjectCasting(): bool { @@ -185,6 +215,10 @@ public function shouldAllowScalarToObjectCasting(): bool /** * Returns a copy with the strict mode flag toggled. + * + * @param bool $enabled Whether strict mode should be enabled for the clone + * + * @return self Cloned configuration reflecting the requested strictness */ public function withStrictMode(bool $enabled): self { @@ -196,6 +230,10 @@ public function withStrictMode(bool $enabled): self /** * Returns a copy with the error collection flag toggled. + * + * @param bool $collect Whether errors should be aggregated in the clone + * + * @return self Cloned configuration applying the collection behaviour */ public function withErrorCollection(bool $collect): self { @@ -207,6 +245,10 @@ public function withErrorCollection(bool $collect): self /** * Returns a copy with the empty-string-as-null flag toggled. + * + * @param bool $enabled Whether empty strings should become null for the clone + * + * @return self Cloned configuration applying the string handling behaviour */ public function withEmptyStringAsNull(bool $enabled): self { @@ -218,6 +260,10 @@ public function withEmptyStringAsNull(bool $enabled): self /** * Returns a copy with the ignore-unknown-properties flag toggled. + * + * @param bool $enabled Whether unknown properties should be ignored in the clone + * + * @return self Cloned configuration reflecting the requested behaviour */ public function withIgnoreUnknownProperties(bool $enabled): self { @@ -229,6 +275,10 @@ public function withIgnoreUnknownProperties(bool $enabled): self /** * Returns a copy with the treat-null-as-empty-collection flag toggled. + * + * @param bool $enabled Whether null collections should be normalised for the clone + * + * @return self Cloned configuration applying the collection normalisation behaviour */ public function withTreatNullAsEmptyCollection(bool $enabled): self { @@ -240,6 +290,10 @@ public function withTreatNullAsEmptyCollection(bool $enabled): self /** * Returns a copy with a different default date format. + * + * @param string $format Desired default format compatible with {@see DateTimeInterface::format()} + * + * @return self Cloned configuration containing the new date format */ public function withDefaultDateFormat(string $format): self { @@ -251,6 +305,10 @@ public function withDefaultDateFormat(string $format): self /** * Returns a copy with the scalar-to-object casting flag toggled. + * + * @param bool $enabled Whether scalar values should be coerced to objects in the clone + * + * @return self Cloned configuration defining the scalar coercion behaviour */ public function withScalarToObjectCasting(bool $enabled): self { diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index 352fe78..e163424 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -54,8 +54,8 @@ final class MappingContext private array $options; /** - * @param mixed $rootInput The original JSON payload - * @param array $options Context options + * @param mixed $rootInput The original JSON payload handed to the mapper + * @param array $options Context options influencing mapping behaviour */ public function __construct(private readonly mixed $rootInput, array $options = []) { @@ -64,6 +64,8 @@ public function __construct(private readonly mixed $rootInput, array $options = /** * Returns the root JSON input value. + * + * @return mixed Original payload that initiated the current mapping run */ public function getRootInput(): mixed { @@ -72,6 +74,8 @@ public function getRootInput(): mixed /** * Returns the current path inside the JSON structure. + * + * @return string Dot-separated path beginning with the root symbol */ public function getPath(): string { @@ -85,7 +89,10 @@ public function getPath(): string /** * Executes the callback while appending the provided segment to the path. * - * @param callable(self):mixed $callback + * @param string|int $segment Segment appended to the path for the callback execution + * @param callable(self):mixed $callback Callback executed while the segment is in place + * + * @return mixed Result produced by the callback */ public function withPathSegment(string|int $segment, callable $callback): mixed { @@ -100,6 +107,11 @@ public function withPathSegment(string|int $segment, callable $callback): mixed /** * Stores the error message for later consumption. + * + * @param string $message Human-readable description of the failure + * @param MappingException|null $exception Optional exception associated with the failure + * + * @return void */ public function addError(string $message, ?MappingException $exception = null): void { @@ -112,6 +124,10 @@ public function addError(string $message, ?MappingException $exception = null): /** * Stores the exception and message for later consumption. + * + * @param MappingException $exception Exception raised during mapping + * + * @return void */ public function recordException(MappingException $exception): void { @@ -121,7 +137,7 @@ public function recordException(MappingException $exception): void /** * Returns collected mapping errors. * - * @return list + * @return list Error messages collected so far */ public function getErrors(): array { @@ -131,26 +147,51 @@ public function getErrors(): array ); } + /** + * Indicates whether mapping errors should be collected instead of throwing immediately. + * + * @return bool True when error aggregation is enabled + */ public function shouldCollectErrors(): bool { return (bool) ($this->options[self::OPTION_COLLECT_ERRORS] ?? true); } + /** + * Indicates whether the mapper operates in strict mode. + * + * @return bool True when missing or unknown properties result in failures + */ public function isStrictMode(): bool { return (bool) ($this->options[self::OPTION_STRICT_MODE] ?? false); } + /** + * Indicates whether unknown properties from the input should be ignored. + * + * @return bool True when extra input properties are silently skipped + */ public function shouldIgnoreUnknownProperties(): bool { return (bool) ($this->options[self::OPTION_IGNORE_UNKNOWN_PROPERTIES] ?? false); } + /** + * Indicates whether null collections should be normalised to empty collections. + * + * @return bool True when null collections are replaced with empty instances + */ public function shouldTreatNullAsEmptyCollection(): bool { return (bool) ($this->options[self::OPTION_TREAT_NULL_AS_EMPTY_COLLECTION] ?? false); } + /** + * Returns the default date format used for date conversions. + * + * @return string Date format string compatible with {@see DateTimeInterface::format()} + */ public function getDefaultDateFormat(): string { $format = $this->options[self::OPTION_DEFAULT_DATE_FORMAT] ?? DateTimeInterface::ATOM; @@ -162,6 +203,11 @@ public function getDefaultDateFormat(): string return $format; } + /** + * Indicates whether scalar values are allowed to be coerced into objects when possible. + * + * @return bool True when scalar-to-object casting is enabled + */ public function shouldAllowScalarToObjectCasting(): bool { return (bool) ($this->options[self::OPTION_ALLOW_SCALAR_TO_OBJECT_CASTING] ?? false); @@ -170,7 +216,7 @@ public function shouldAllowScalarToObjectCasting(): bool /** * Returns all options. * - * @return array + * @return array Associative array of context options */ public function getOptions(): array { @@ -179,6 +225,11 @@ public function getOptions(): array /** * Returns a single option by name. + * + * @param string $name Option name as defined by the {@see self::OPTION_*} constants + * @param mixed $default Fallback value returned when the option is not set + * + * @return mixed Stored option value or the provided default */ public function getOption(string $name, mixed $default = null): mixed { @@ -188,7 +239,9 @@ public function getOption(string $name, mixed $default = null): mixed /** * Replaces the stored options. * - * @param array $options + * @param array $options Complete set of options to store + * + * @return void */ public function replaceOptions(array $options): void { @@ -198,18 +251,30 @@ public function replaceOptions(array $options): void /** * Returns collected mapping errors with contextual details. * - * @return list + * @return list Error records including message, path, and exception */ public function getErrorRecords(): array { return $this->errorRecords; } + /** + * Returns the number of collected errors currently stored in the context. + * + * @return int Count of collected errors + */ public function getErrorCount(): int { return count($this->errorRecords); } + /** + * Truncates the stored errors to the given number of entries. + * + * @param int $count Maximum number of records to retain + * + * @return void + */ public function trimErrors(int $count): void { $this->errorRecords = array_slice($this->errorRecords, 0, $count); diff --git a/src/JsonMapper/Context/MappingError.php b/src/JsonMapper/Context/MappingError.php index 568bcbe..6aa9065 100644 --- a/src/JsonMapper/Context/MappingError.php +++ b/src/JsonMapper/Context/MappingError.php @@ -18,6 +18,11 @@ */ final readonly class MappingError { + /** + * @param string $path JSON path pointing to the failing property + * @param string $message Human-readable description of the failure + * @param MappingException|null $exception Exception that triggered the error, when available + */ public function __construct( private string $path, private string $message, @@ -25,16 +30,31 @@ public function __construct( ) { } + /** + * Returns the JSON path that triggered the error. + * + * @return string Path formatted using dot notation starting at the root symbol + */ public function getPath(): string { return $this->path; } + /** + * Returns the descriptive error message. + * + * @return string Human-readable explanation of the failure + */ public function getMessage(): string { return $this->message; } + /** + * Returns the exception instance associated with the error, when one was recorded. + * + * @return MappingException|null Underlying exception or null when only a message was recorded + */ public function getException(): ?MappingException { return $this->exception; From 6a29eaedf2433c9b9a72f6f2ff866fc402560479 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:33:06 +0100 Subject: [PATCH 30/46] docs: clarify phpdoc for collection resolvers --- .../Collection/CollectionDocBlockTypeResolver.php | 9 +++++++-- src/JsonMapper/Collection/CollectionFactory.php | 12 +++++++++--- .../Collection/CollectionFactoryInterface.php | 12 ++++++++---- src/JsonMapper/Resolver/ClassResolver.php | 14 +++++++------- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php index 3850226..3afd078 100644 --- a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -36,6 +36,11 @@ final class CollectionDocBlockTypeResolver { private DocBlockFactoryInterface $docBlockFactory; + /** + * @param DocBlockFactoryInterface|null $docBlockFactory Optional docblock factory used to parse collection annotations. + * @param ContextFactory $contextFactory Factory for building type resolution contexts for reflected classes. + * @param PhpDocTypeHelper $phpDocTypeHelper Helper translating DocBlock types into Symfony TypeInfo representations. + */ public function __construct( ?DocBlockFactoryInterface $docBlockFactory = null, private readonly ContextFactory $contextFactory = new ContextFactory(), @@ -56,9 +61,9 @@ public function __construct( /** * Attempts to resolve a {@see CollectionType} from the collection class PHPDoc. * - * @param class-string $collectionClassName + * @param class-string $collectionClassName Fully qualified class name of the collection wrapper to inspect. * - * @return CollectionType>|null + * @return CollectionType>|null Resolved collection metadata or null when no matching PHPDoc is available. */ public function resolve(string $collectionClassName): ?CollectionType { diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index 4680abb..e0b8496 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -54,7 +54,11 @@ public function __construct( /** * Converts the provided iterable JSON structure to a PHP array. * - * @return array|null + * @param mixed $json Raw JSON data representing the collection to hydrate. + * @param Type $valueType Type descriptor for individual collection entries. + * @param MappingContext $context Active mapping context providing path and strictness information. + * + * @return array|null Normalised collection data or null when conversion fails. */ public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array { @@ -96,9 +100,11 @@ public function mapIterable(mixed $json, Type $valueType, MappingContext $contex /** * Builds a collection based on the specified collection type description. * - * @param CollectionType> $type + * @param CollectionType> $type Resolved collection metadata from docblocks or attributes. + * @param mixed $json Raw JSON payload containing the collection values. + * @param MappingContext $context Mapping context controlling strict mode and error tracking. * - * @return object|array|null + * @return object|array|null Instantiated collection wrapper or the normalised array values. */ public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): array|object|null { diff --git a/src/JsonMapper/Collection/CollectionFactoryInterface.php b/src/JsonMapper/Collection/CollectionFactoryInterface.php index eee3018..1ca8fbf 100644 --- a/src/JsonMapper/Collection/CollectionFactoryInterface.php +++ b/src/JsonMapper/Collection/CollectionFactoryInterface.php @@ -32,18 +32,22 @@ interface CollectionFactoryInterface /** * Converts the provided iterable JSON structure to a PHP array. * - * @param Type $valueType The type description for the collection values. + * @param mixed $json Raw JSON data representing the iterable input to normalise. + * @param Type $valueType Type description for the collection values. + * @param MappingContext $context Active mapping context carrying strictness and error reporting configuration. * - * @return array|null + * @return array|null Normalised array representation or null when conversion fails. */ public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array; /** * Builds a collection based on the specified collection type description. * - * @param CollectionType> $type The collection type metadata extracted from PHPStan/Psalm annotations. + * @param CollectionType> $type Resolved collection metadata from PHPDoc or attributes. + * @param mixed $json Raw JSON payload containing the collection values. + * @param MappingContext $context Mapping context controlling strict mode and error recording. * - * @return array|object|null + * @return array|object|null Instantiated collection wrapper or the normalised array values. */ public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed; } diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index c19bf7f..d481bbd 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -36,7 +36,7 @@ final class ClassResolver private array $classMap; /** - * @param array $classMap + * @param array $classMap Map of base class names to explicit targets or resolver callbacks. * * @phpstan-param array $classMap */ @@ -48,8 +48,8 @@ public function __construct(array $classMap = []) /** * Adds a custom resolution rule. * - * @param class-string $className - * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver + * @param class-string $className Base class or interface the resolver handles. + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver Callback returning a concrete class based on the JSON payload and optional mapping context. * * @phpstan-param class-string $className * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver @@ -63,11 +63,11 @@ public function add(string $className, Closure $resolver): void /** * Resolves the class name for the provided JSON payload. * - * @param class-string $className - * @param mixed $json - * @param MappingContext $context + * @param class-string $className Base class name configured in the resolver map. + * @param mixed $json Raw JSON fragment inspected to determine the target class. + * @param MappingContext $context Mapping context passed to resolution callbacks when required. * - * @return class-string + * @return class-string Fully-qualified class name that should be instantiated for the payload. */ public function resolve(string $className, mixed $json, MappingContext $context): string { From d2d0cc506d25aee292f888c4188b2453eb23c350 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:52:08 +0100 Subject: [PATCH 31/46] docs: reorder nested example types --- README.md | 325 ++++++++++++++++++++------ docs/API.md | 92 ++++++-- docs/recipes/custom-name-converter.md | 24 +- docs/recipes/mapping-with-enums.md | 28 ++- docs/recipes/nested-collections.md | 51 +++- docs/recipes/using-attributes.md | 10 + 6 files changed, 425 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 8520ba3..8725dc6 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,12 @@ final class ArticleCollection extends ArrayObject final class Article { public string $title; - - /** @var CommentCollection */ public CommentCollection $comments; } ``` ```php -declare(strict_types=1); +require __DIR__ . '/vendor/autoload.php'; use App\Dto\Article; use App\Dto\ArticleCollection; @@ -68,19 +66,25 @@ use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +// Decode a single article and a list of articles, raising on malformed JSON. $single = json_decode('{"title":"Hello world","comments":[{"message":"First!"}]}', associative: false, flags: JSON_THROW_ON_ERROR); $list = json_decode('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]', associative: false, flags: JSON_THROW_ON_ERROR); +// Configure JsonMapper with reflection and PhpDoc support. $propertyInfo = new PropertyInfoExtractor( - [new ReflectionExtractor()], - [new PhpDocExtractor()] + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], ); $propertyAccessor = PropertyAccess::createPropertyAccessor(); $mapper = new JsonMapper($propertyInfo, $propertyAccessor); +// Map a single DTO and an entire collection in one go. $article = $mapper->map($single, Article::class); $articles = $mapper->map($list, Article::class, ArticleCollection::class); + +// Dump the results to verify the hydrated structures. +var_dump($article, $articles); ``` The first call produces an `Article` instance with a populated `CommentCollection`; the second call returns an `ArticleCollection` containing `Article` objects. @@ -96,9 +100,9 @@ values. For example: ```php -@var SomeCollection -@var SomeCollection -@var Collection\SomeCollection +/** @var SomeCollection $dates */ +/** @var SomeCollection $labels */ +/** @var Collection\\SomeCollection $entities */ ``` @@ -116,11 +120,18 @@ certain type (e.g. array), but the API call itself then returns NULL if no data instead of an empty array that can be expected. ```php -/** - * @var array - */ -#[MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue] -public array $array = []; +namespace App\Dto; + +use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; + +final class AttributeExample +{ + /** + * @var array + */ + #[ReplaceNullWithDefaultValue] + public array $roles = []; +} ``` If the mapping tries to assign NULL to the property, the default value will be used, as annotated. @@ -131,13 +142,14 @@ used in class context. For instance if you want to replace a cryptic named property to a more human-readable name. ```php -#[MagicSunday\JsonMapper\Attribute\ReplaceProperty('type', replaces: 'crypticTypeNameProperty')] -class FooClass +namespace App\Dto; + +use MagicSunday\JsonMapper\Attribute\ReplaceProperty; + +#[ReplaceProperty('type', replaces: 'crypticTypeNameProperty')] +final class FooClass { - /** - * @var string - */ - public $type; + public string $type; } ``` @@ -155,46 +167,46 @@ your needs. To use the `PhpDocExtractor` extractor you need to install the `phpdocumentor/reflection-docblock` library too. ```php -use \Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; -use \Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; -use \Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use \Symfony\Component\PropertyAccess\PropertyAccessor; -``` +require __DIR__ . '/vendor/autoload.php'; -A common extractor setup: -```php -$listExtractors = [ new ReflectionExtractor() ]; -$typeExtractors = [ new PhpDocExtractor() ]; -$propertyInfoExtractor = new PropertyInfoExtractor($listExtractors, $typeExtractors); -``` +use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -Create an instance of the property accessor: -```php +final class SdkFoo +{ +} + +final class Foo +{ +} + +// Gather Symfony extractors that describe available DTO properties. +$propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], +); + +// Build a property accessor so JsonMapper can read and write DTO values. $propertyAccessor = PropertyAccess::createPropertyAccessor(); -``` -Using the third argument you can pass a property name converter instance to the mapper. With this you can convert -the JSON property names to you desired format your PHP classes are using. -```php -$nameConverter = new \MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter(); -``` +// Convert snake_case JSON keys into camelCase DTO properties. +$nameConverter = new CamelCasePropertyNameConverter(); -The last constructor parameter allows you to pass a class map to JsonMapper in order to change the default mapping -behaviour. For instance if you have an SDK which maps the JSON response of a webservice to PHP. Using the class map you could override -the default mapping to the SDK's classes by providing an alternative list of classes used to map. -```php +// Provide explicit class-map overrides when API classes differ from DTOs. $classMap = [ SdkFoo::class => Foo::class, ]; -``` -Create an instance of the JsonMapper: -```php -$mapper = new \MagicSunday\JsonMapper( - $propertyInfoExtractor, +// Finally create the mapper with the configured dependencies. +$mapper = new JsonMapper( + $propertyInfo, $propertyAccessor, $nameConverter, - $classMap + $classMap, ); ``` @@ -204,64 +216,152 @@ special treatment if an object of type Bar should be mapped: You may alternatively implement `\MagicSunday\JsonMapper\Value\TypeHandlerInterface` to package reusable handlers. ```php +require __DIR__ . '/vendor/autoload.php'; + +use DateTimeImmutable; +use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper\Value\ClosureTypeHandler; +use stdClass; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +final class Bar +{ + public function __construct(public string $name) + { + } +} + +final class Wrapper +{ + public Bar $bar; + public DateTimeImmutable $createdAt; +} + +// Describe DTO properties through Symfony extractors. +$propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); + +// Register a handler that hydrates Bar value objects from nested stdClass payloads. $mapper->addTypeHandler( - new \MagicSunday\JsonMapper\Value\ClosureTypeHandler( + new ClosureTypeHandler( Bar::class, - /** @var mixed $value JSON data */ - static function ($value): ?Bar { - return $value ? new Bar($value['name']) : null; + static function (stdClass $value): Bar { + // Convert the decoded JSON object into a strongly typed Bar instance. + return new Bar($value->name); }, ), ); -``` -or add a handler to map DateTime values: -```php +// Register a handler for DateTimeImmutable conversion using ISO-8601 timestamps. $mapper->addTypeHandler( - new \MagicSunday\JsonMapper\Value\ClosureTypeHandler( - \DateTime::class, - /** @var mixed $value JSON data */ - static function ($value): ?\DateTime { - return $value ? new \DateTime($value) : null; + new ClosureTypeHandler( + DateTimeImmutable::class, + static function (string $value): DateTimeImmutable { + return new DateTimeImmutable($value); }, ), ); + +// Decode the JSON payload while throwing on malformed input. +$payload = json_decode('{"bar":{"name":"custom"},"createdAt":"2024-01-01T10:00:00+00:00"}', associative: false, flags: JSON_THROW_ON_ERROR); + +// Map the payload into the Wrapper DTO. +$result = $mapper->map($payload, Wrapper::class); + +var_dump($result); ``` Convert a JSON string into a JSON array/object using PHPs built in method `json_decode` ```php -$json = json_decode('JSON STRING', true, 512, JSON_THROW_ON_ERROR); +// Decode the JSON document while propagating parser errors. +$json = json_decode('{"title":"Sample"}', associative: false, flags: JSON_THROW_ON_ERROR); + +// Inspect the decoded representation. +var_dump($json); ``` Call method `map` to do the actual mapping of the JSON object/array into PHP classes. Pass the initial class name and optional the name of a collection class to the method. ```php +require __DIR__ . '/vendor/autoload.php'; + +use ArrayObject; +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +final class FooCollection extends ArrayObject +{ +} + +final class Foo +{ + public string $name; +} + +// Decode a JSON array into objects and throw on malformed payloads. +$json = json_decode('[{"name":"alpha"},{"name":"beta"}]', associative: false, flags: JSON_THROW_ON_ERROR); + +// Configure JsonMapper with reflection and PHPDoc metadata. +$propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); + +// Map the collection into Foo instances stored inside FooCollection. $mappedResult = $mapper->map($json, Foo::class, FooCollection::class); + +var_dump($mappedResult); ``` A complete set-up may look like this: ```php +require __DIR__ . '/vendor/autoload.php'; + +use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + /** - * Returns an instance of the JsonMapper for testing. - * - * @param string[]|Closure[] $classMap A class map to override the class names + * Bootstrap a JsonMapper instance with Symfony extractors and optional class-map overrides. * - * @return \MagicSunday\JsonMapper + * @param array $classMap Override source classes with DTO replacements. */ -protected function getJsonMapper(array $classMap = []): \MagicSunday\JsonMapper +function createJsonMapper(array $classMap = []): JsonMapper { - $listExtractors = [ new ReflectionExtractor() ]; - $typeExtractors = [ new PhpDocExtractor() ]; - $extractor = new PropertyInfoExtractor($listExtractors, $typeExtractors); + // Cache property metadata to avoid repeated reflection work. + $propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], + ); - return new \MagicSunday\JsonMapper( - $extractor, + // Return a mapper configured with a camelCase converter and optional overrides. + return new JsonMapper( + $propertyInfo, PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), - $classMap + $classMap, ); } + +$mapper = createJsonMapper(); ``` ### Type converters and custom class maps @@ -270,7 +370,38 @@ Custom types should implement `MagicSunday\\JsonMapper\\Value\\TypeHandlerInterf Use `JsonMapper::addCustomClassMapEntry()` when the target class depends on runtime data. The resolver receives the decoded JSON payload and may inspect a `MappingContext` when you need additional state. ```php +require __DIR__ . '/vendor/autoload.php'; + +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +final class SdkFoo +{ +} + +final class FooBar +{ +} + +final class FooBaz +{ +} + +// Build the dependencies shared by all mapping runs. +$propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); + +// Route SDK payloads to specific DTOs based on runtime discriminator data. $mapper->addCustomClassMapEntry(SdkFoo::class, static function (array $payload): string { + // Decide which DTO to instantiate by inspecting the payload type. return $payload['type'] === 'bar' ? FooBar::class : FooBaz::class; }); ``` @@ -279,12 +410,39 @@ $mapper->addCustomClassMapEntry(SdkFoo::class, static function (array $payload): The mapper operates in a lenient mode by default. Switch to strict mapping when every property must be validated: ```php -use MagicSunday\\JsonMapper\\Configuration\\JsonMapperConfiguration; +require __DIR__ . '/vendor/autoload.php'; + +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +final class Article +{ + public string $title; +} -$config = JsonMapperConfiguration::strict() - ->withCollectErrors(true); +// Decode the JSON payload that should comply with the DTO schema. +$payload = json_decode('{"title":"Strict example"}', associative: false, flags: JSON_THROW_ON_ERROR); +// Prepare the mapper with Symfony metadata extractors. +$propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); + +// Enable strict validation and collect every encountered error. +$config = JsonMapperConfiguration::strict()->withCollectErrors(true); + +// Map while receiving a result object that contains the mapped DTO and issues. $result = $mapper->mapWithReport($payload, Article::class, configuration: $config); + +var_dump($result->getMappedValue()); ``` For tolerant APIs combine `JsonMapperConfiguration::lenient()` with `->withIgnoreUnknownProperties(true)` or `->withTreatNullAsEmptyCollection(true)` to absorb schema drifts. @@ -293,8 +451,23 @@ For tolerant APIs combine `JsonMapperConfiguration::lenient()` with `->withIgnor Type resolution is the most expensive part of a mapping run. Provide a PSR-6 cache pool to the constructor to reuse computed `Type` metadata: ```php -use Symfony\\Component\\Cache\\Adapter\\ArrayAdapter; +require __DIR__ . '/vendor/autoload.php'; + +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +// Assemble the reflection and PHPDoc extractors once. +$propertyInfo = new PropertyInfoExtractor( + listExtractors: [new ReflectionExtractor()], + typeExtractors: [new PhpDocExtractor()], +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); +// Cache resolved Type metadata between mapping runs. $cache = new ArrayAdapter(); $mapper = new JsonMapper($propertyInfo, $propertyAccessor, nameConverter: null, classMap: [], typeCache: $cache); ``` diff --git a/docs/API.md b/docs/API.md index fe660bf..3556f5e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,15 +6,30 @@ This document summarises the public surface of the JsonMapper package. All class The `JsonMapper` class is the main entry point for mapping arbitrary JSON structures to PHP objects. The class is `final`; prefer composition over inheritance. ### Constructor -``` -__construct( - PropertyInfoExtractorInterface $extractor, - PropertyAccessorInterface $accessor, - ?PropertyNameConverterInterface $nameConverter = null, - array $classMap = [], - ?CacheItemPoolInterface $typeCache = null, - JsonMapperConfiguration $config = new JsonMapperConfiguration(), -) +```php +getClassName() === Uuid::class; + // Only handle FakeUuid targets to keep conversion focused. + return $type instanceof ObjectType && $type->getClassName() === FakeUuid::class; } - public function convert(Type $type, mixed $value, MappingContext $context): Uuid + public function convert(Type $type, mixed $value, MappingContext $context): FakeUuid { - return Uuid::fromString((string) $value); + // Build the value object from the incoming scalar payload. + return FakeUuid::fromString((string) $value); } } ``` diff --git a/docs/recipes/custom-name-converter.md b/docs/recipes/custom-name-converter.md index dc12c18..a87ca8c 100644 --- a/docs/recipes/custom-name-converter.md +++ b/docs/recipes/custom-name-converter.md @@ -3,23 +3,43 @@ Property name converters translate JSON keys to PHP property names. JsonMapper provides `CamelCasePropertyNameConverter` out of the box and allows you to supply your own implementation of `PropertyNameConverterInterface`. ```php +map($json, Article::class); +$article = $mapper->map($json, Article::class, configuration: $configuration); assert($article instanceof Article); assert($article->status === Status::Published); ``` - The mapper validates enum values. When strict mode is enabled (`JsonMapperConfiguration::strict()`), an invalid enum value results in a `TypeMismatchException`. diff --git a/docs/recipes/nested-collections.md b/docs/recipes/nested-collections.md index 9f4a2b9..4eb677f 100644 --- a/docs/recipes/nested-collections.md +++ b/docs/recipes/nested-collections.md @@ -3,19 +3,29 @@ Collections of collections require explicit metadata so JsonMapper can determine the element types at every level. ```php + + * @extends ArrayObject */ -final class TagCollection extends \ArrayObject +final class TagCollection extends ArrayObject { } /** - * @extends \ArrayObject + * @extends ArrayObject */ -final class NestedTagCollection extends \ArrayObject +final class NestedTagCollection extends ArrayObject { } @@ -24,15 +34,32 @@ final class Article /** @var NestedTagCollection */ public NestedTagCollection $tags; } + +/** + * @extends ArrayObject + */ +final class ArticleCollection extends ArrayObject +{ +} ``` ```php +map($json, Article::class, \ArrayObject::class); +$articles = $mapper->map($json, Article::class, ArticleCollection::class); -assert($articles instanceof \ArrayObject); +assert($articles instanceof ArticleCollection); assert($articles[0] instanceof Article); assert($articles[0]->tags instanceof NestedTagCollection); ``` diff --git a/docs/recipes/using-attributes.md b/docs/recipes/using-attributes.md index 483bc80..46e9b79 100644 --- a/docs/recipes/using-attributes.md +++ b/docs/recipes/using-attributes.md @@ -6,6 +6,11 @@ JsonMapper ships with attributes that can refine how JSON data is mapped to PHP Use this attribute on properties that should fall back to their default value when the JSON payload explicitly contains `null`. ```php + Date: Fri, 14 Nov 2025 10:55:53 +0100 Subject: [PATCH 32/46] docs(attribute): document replace property constructor --- src/JsonMapper/Attribute/ReplaceProperty.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/JsonMapper/Attribute/ReplaceProperty.php b/src/JsonMapper/Attribute/ReplaceProperty.php index cff49a6..8263b28 100644 --- a/src/JsonMapper/Attribute/ReplaceProperty.php +++ b/src/JsonMapper/Attribute/ReplaceProperty.php @@ -19,6 +19,10 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final readonly class ReplaceProperty { + /** + * @param string $value Name of the incoming JSON field that should be renamed. + * @param string $replaces Target property name that receives the value. + */ public function __construct( public string $value, public string $replaces, From 7d7a94eeeb79497f79addc75aaf5abd54e1fc84b Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:56:13 +0100 Subject: [PATCH 33/46] docs(converter): clarify property name conversion phpdoc --- .../Converter/CamelCasePropertyNameConverter.php | 12 ++++++++++++ .../Converter/PropertyNameConverterInterface.php | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php index 38ba03a..63eacff 100644 --- a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php +++ b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php @@ -25,11 +25,23 @@ { private Inflector $inflector; + /** + * Creates the converter with the Doctrine inflector responsible for camel case transformations. + * + * The inflector dependency is initialised here so it can be reused for every conversion. + */ public function __construct() { $this->inflector = InflectorFactory::create()->build(); } + /** + * Converts a raw JSON property name to the camelCase variant expected by PHP properties. + * + * @param string $name Raw property name as provided by the JSON payload. + * + * @return string Normalised camelCase property name that matches PHP naming conventions. + */ public function convert(string $name): string { return $this->inflector->camelize($name); diff --git a/src/JsonMapper/Converter/PropertyNameConverterInterface.php b/src/JsonMapper/Converter/PropertyNameConverterInterface.php index 80c3c28..de5097d 100644 --- a/src/JsonMapper/Converter/PropertyNameConverterInterface.php +++ b/src/JsonMapper/Converter/PropertyNameConverterInterface.php @@ -23,9 +23,9 @@ interface PropertyNameConverterInterface /** * Convert the specified JSON property name to its PHP property name. * - * @param string $name + * @param string $name Raw property name exactly as it appears in the JSON structure. * - * @return string + * @return string Normalised PHP property name that matches the target object's naming scheme. */ public function convert(string $name): string; } From 206e8441f0b4187117e0564775219c59fec87443 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:56:34 +0100 Subject: [PATCH 34/46] docs(value): document closure type handler callables --- src/JsonMapper/Value/ClosureTypeHandler.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/JsonMapper/Value/ClosureTypeHandler.php b/src/JsonMapper/Value/ClosureTypeHandler.php index 741026e..236ef30 100644 --- a/src/JsonMapper/Value/ClosureTypeHandler.php +++ b/src/JsonMapper/Value/ClosureTypeHandler.php @@ -27,11 +27,21 @@ final class ClosureTypeHandler implements TypeHandlerInterface { private Closure $converter; + /** + * @param class-string $className Fully-qualified class name the handler is responsible for. + * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter Callable receiving the mapped value and + * optionally the mapping context. + */ public function __construct(private readonly string $className, callable $converter) { $this->converter = $this->normalizeConverter($converter); } + /** + * Determines whether the given type is supported. + * + * The handler accepts only object types that match the configured class name; other type instances are rejected. + */ public function supports(Type $type, mixed $value): bool { if (!$type instanceof ObjectType) { @@ -41,6 +51,11 @@ public function supports(Type $type, mixed $value): bool return $type->getClassName() === $this->className; } + /** + * Converts the provided value to the supported type using the configured converter. + * + * @throws LogicException When the supplied type is not supported by this handler. + */ public function convert(Type $type, mixed $value, MappingContext $context): mixed { if (!$this->supports($type, $value)) { @@ -51,6 +66,11 @@ public function convert(Type $type, mixed $value, MappingContext $context): mixe } /** + * Normalizes a user-supplied callable into the internal converter signature. + * + * The converter may accept either one argument (the value) or two arguments (value and mapping context). Single-argument + * callables are wrapped so that the mapping context can be provided when invoking the handler. + * * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter */ private function normalizeConverter(callable $converter): Closure @@ -62,6 +82,7 @@ private function normalizeConverter(callable $converter): Closure return $closure; } + // Ensure the converter always accepts the mapping context even if the original callable does not need it. return static fn (mixed $value, MappingContext $context): mixed => $closure($value); } } From 8fd4c53a260d8dff77de935562c8e8849565b1f2 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:57:02 +0100 Subject: [PATCH 35/46] docs(resolver): document helper behaviours --- src/JsonMapper/Resolver/ClassResolver.php | 25 ++++++++++++---- src/JsonMapper/Type/TypeResolver.php | 35 +++++++++++++++++++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index d481bbd..e39dbd4 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -97,12 +97,19 @@ public function resolve(string $className, mixed $json, MappingContext $context) } /** - * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver + * Executes a resolver callback while adapting the invocation to its declared arity. + * + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver User-provided resolver that determines the concrete class; the parameter list defines whether the mapping context can be injected. + * @param mixed $json JSON fragment forwarded to the resolver so it can inspect discriminator values. + * @param MappingContext $context Context object passed when supported to supply additional mapping metadata. + * + * @return mixed Raw resolver result that will subsequently be validated as a class-string. */ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $context): mixed { $reflection = new ReflectionFunction($resolver); + // Inspect the closure signature to decide whether to pass the mapping context argument. if ($reflection->getNumberOfParameters() >= 2) { return $resolver($json, $context); } @@ -111,11 +118,13 @@ private function invokeResolver(Closure $resolver, mixed $json, MappingContext $ } /** - * Validates the configured class map entries eagerly. + * Validates the configured class map entries eagerly to fail fast on invalid definitions. + * + * @param array $classMap Map of discriminated base classes to either target classes or resolver closures; each entry is asserted for existence. * - * @param array $classMap + * @return array Sanitised map ready for runtime lookups. * - * @return array + * @throws DomainException When a class key or mapped class name is empty or cannot be autoloaded. */ private function validateClassMap(array $classMap): array { @@ -133,9 +142,13 @@ private function validateClassMap(array $classMap): array } /** - * @return class-string + * Ensures the provided class reference is non-empty and refers to a loadable class or interface. + * + * @param string $className Candidate class-string; invalid or unknown names trigger a DomainException. + * + * @return class-string Validated class or interface name safe to return to callers. * - * @throws DomainException + * @throws DomainException When the name is empty or cannot be resolved by the autoloader. */ private function assertClassString(string $className): string { diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index f6eca10..87e7a47 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -73,6 +73,13 @@ public function resolve(string $className, string $propertyName): Type return $resolved; } + /** + * Normalizes Symfony Type instances to collapse nested unions and propagate nullability. + * + * @param Type $type Type extracted from metadata; union instances trigger recursive normalization. + * + * @return Type Provided type or its normalized equivalent when unions are involved. + */ private function normalizeType(Type $type): Type { if ($type instanceof UnionType) { @@ -147,7 +154,12 @@ private function buildCacheKey(string $className, string $propertyName): string } /** - * @param class-string $className + * Falls back to native reflection when PropertyInfo does not expose metadata for a property. + * + * @param class-string $className Declaring class inspected via reflection; invalid classes yield null. + * @param string $propertyName Name of the property to inspect; missing properties short-circuit to null. + * + * @return Type|null Type derived from the reflected signature, including nullability, or null when no type hint exists. */ private function resolveFromReflection(string $className, string $propertyName): ?Type { @@ -201,6 +213,14 @@ private function resolveFromReflection(string $className, string $propertyName): return null; } + /** + * Translates a reflected named type into the internal Type representation while preserving nullability. + * + * @param ReflectionNamedType $type Native type declaration; builtin names map to builtin identifiers, class names to object types. + * @param bool|null $nullable Overrides the reflection nullability flag when provided; null defers to the reflection metadata. + * + * @return Type|null Resolved Type instance or null when the builtin name is unsupported. + */ private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool $nullable = null): ?Type { $name = $type->getName(); @@ -227,7 +247,11 @@ private function createTypeFromNamedReflection(ReflectionNamedType $type, ?bool } /** - * @param UnionType $type + * Consolidates union members and ensures nullability is represented via Type::nullable when required. + * + * @param UnionType $type Union derived from metadata; its members are recursively normalized and inspected for null. + * + * @return Type Normalized union instance or nullable default when only null remains. */ private function normalizeUnionType(UnionType $type): Type { @@ -257,6 +281,13 @@ private function normalizeUnionType(UnionType $type): Type return $union; } + /** + * Determines whether a type entry represents the null literal within a union. + * + * @param Type $type Candidate inspected while normalizing unions; controls whether nullable wrappers are applied. + * + * @return bool True when the type corresponds to the null builtin identifier. + */ private function isNullType(Type $type): bool { return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; From 26b6ec6b3ada42e9085c5b8fe7e5b9e3aca67bd6 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:57:29 +0100 Subject: [PATCH 36/46] docs(report): clarify mapping report phpdocs --- src/JsonMapper/Report/MappingReport.php | 10 ++++++++++ src/JsonMapper/Report/MappingResult.php | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php index 9e6c55d..deff5d5 100644 --- a/src/JsonMapper/Report/MappingReport.php +++ b/src/JsonMapper/Report/MappingReport.php @@ -35,11 +35,21 @@ public function getErrors(): array return $this->errors; } + /** + * Determines whether the report contains any mapping errors. + * + * @return bool True when at least one {@see MappingError} has been collected, false otherwise. + */ public function hasErrors(): bool { return $this->errors !== []; } + /** + * Counts the number of mapping errors stored in the report. + * + * @return int Total amount of collected {@see MappingError} instances. + */ public function getErrorCount(): int { return count($this->errors); diff --git a/src/JsonMapper/Report/MappingResult.php b/src/JsonMapper/Report/MappingResult.php index c6056d0..1b62d67 100644 --- a/src/JsonMapper/Report/MappingResult.php +++ b/src/JsonMapper/Report/MappingResult.php @@ -16,17 +16,27 @@ */ final readonly class MappingResult { + /** + * @param mixed $value The mapped value returned by the mapper. + * @param MappingReport $report Report containing diagnostics for the mapping operation. + */ public function __construct( private mixed $value, private MappingReport $report, ) { } + /** + * Returns the mapped value that was produced by the mapper. + */ public function getValue(): mixed { return $this->value; } + /** + * Provides the report with the diagnostics gathered during mapping. + */ public function getReport(): MappingReport { return $this->report; From d6005dc1a599db3cfe3196defd4dadf991d31477 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:57:51 +0100 Subject: [PATCH 37/46] docs(exception): clarify exception phpdoc --- .../Exception/CollectionMappingException.php | 12 +++++++++++ src/JsonMapper/Exception/MappingException.php | 14 +++++++++++++ .../Exception/MissingPropertyException.php | 18 +++++++++++++++- .../Exception/ReadonlyPropertyException.php | 5 +++++ .../Exception/TypeMismatchException.php | 21 +++++++++++++++++++ .../Exception/UnknownPropertyException.php | 19 ++++++++++++++++- 6 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/JsonMapper/Exception/CollectionMappingException.php b/src/JsonMapper/Exception/CollectionMappingException.php index 22824db..30add6e 100644 --- a/src/JsonMapper/Exception/CollectionMappingException.php +++ b/src/JsonMapper/Exception/CollectionMappingException.php @@ -18,6 +18,10 @@ */ final class CollectionMappingException extends MappingException { + /** + * @param string $path Path to the collection that failed to map. + * @param string $actualType Type reported for the value that was expected to be iterable. + */ public function __construct(string $path, private readonly string $actualType) { parent::__construct( @@ -26,6 +30,14 @@ public function __construct(string $path, private readonly string $actualType) ); } + /** + * Returns the detected type of the value that could not be treated as iterable. + * + * Callers can surface the type to API consumers to explain why the mapper refused + * to process the collection. + * + * @return string Type information describing the non-iterable value. + */ public function getActualType(): string { return $this->actualType; diff --git a/src/JsonMapper/Exception/MappingException.php b/src/JsonMapper/Exception/MappingException.php index 6327a43..4931852 100644 --- a/src/JsonMapper/Exception/MappingException.php +++ b/src/JsonMapper/Exception/MappingException.php @@ -19,6 +19,12 @@ */ abstract class MappingException extends RuntimeException { + /** + * @param string $message Human readable description of the failure scenario. + * @param string $path JSON pointer or dotted path identifying the failing value. + * @param int $code Optional error code to bubble up to the caller. + * @param Throwable|null $previous Underlying cause, if the exception wraps another failure. + */ public function __construct( string $message, private readonly string $path, @@ -28,6 +34,14 @@ public function __construct( parent::__construct($message, $code, $previous); } + /** + * Returns the JSON path pointing to the value that could not be mapped. + * + * Callers can use the path to inform end users about the exact location of the + * mapping problem or to log structured diagnostics. + * + * @return string JSON pointer or dotted path describing the failing location. + */ public function getPath(): string { return $this->path; diff --git a/src/JsonMapper/Exception/MissingPropertyException.php b/src/JsonMapper/Exception/MissingPropertyException.php index bc3344d..8e3a72e 100644 --- a/src/JsonMapper/Exception/MissingPropertyException.php +++ b/src/JsonMapper/Exception/MissingPropertyException.php @@ -18,6 +18,11 @@ */ final class MissingPropertyException extends MappingException { + /** + * @param string $path Path indicating where the missing property should have been present. + * @param string $propertyName Name of the required property defined on the PHP target. + * @param class-string $className Fully qualified name of the DTO or object declaring the property. + */ public function __construct( string $path, private readonly string $propertyName, @@ -30,13 +35,24 @@ public function __construct( ); } + /** + * Returns the required property name that could not be resolved from the JSON input. + * + * Use this to inform API clients about the field they need to provide. + * + * @return string Name of the missing property. + */ public function getPropertyName(): string { return $this->propertyName; } /** - * @return class-string + * Provides the class in which the missing property is declared. + * + * Consumers may use the information to scope the validation error when working with nested DTOs. + * + * @return class-string Fully qualified class name declaring the missing property. */ public function getClassName(): string { diff --git a/src/JsonMapper/Exception/ReadonlyPropertyException.php b/src/JsonMapper/Exception/ReadonlyPropertyException.php index 48e5256..80c2687 100644 --- a/src/JsonMapper/Exception/ReadonlyPropertyException.php +++ b/src/JsonMapper/Exception/ReadonlyPropertyException.php @@ -18,6 +18,11 @@ */ final class ReadonlyPropertyException extends MappingException { + /** + * @param string $path Path pointing to the JSON field that tried to set the readonly property. + * @param string $property Name of the property that cannot be written. + * @param string $className Fully qualified class declaring the readonly property. + */ public function __construct(string $path, string $property, string $className) { parent::__construct( diff --git a/src/JsonMapper/Exception/TypeMismatchException.php b/src/JsonMapper/Exception/TypeMismatchException.php index 4c76130..14d9aae 100644 --- a/src/JsonMapper/Exception/TypeMismatchException.php +++ b/src/JsonMapper/Exception/TypeMismatchException.php @@ -18,6 +18,11 @@ */ final class TypeMismatchException extends MappingException { + /** + * @param string $path Path to the offending value inside the JSON payload. + * @param string $expectedType Type declared on the PHP target (FQCN or scalar type name). + * @param string $actualType Detected type of the JSON value that failed conversion. + */ public function __construct( string $path, private readonly string $expectedType, @@ -29,11 +34,27 @@ public function __construct( ); } + /** + * Returns the PHP type the mapper attempted to hydrate. + * + * Callers may use the information to build error messages that mirror the + * DTO or property contract. + * + * @return string Declared PHP type expected for the JSON value. + */ public function getExpectedType(): string { return $this->expectedType; } + /** + * Returns the actual type the mapper observed in the JSON payload. + * + * Consumers can combine the value with {@see getExpectedType()} to explain + * why the assignment failed. + * + * @return string Type reported for the source value. + */ public function getActualType(): string { return $this->actualType; diff --git a/src/JsonMapper/Exception/UnknownPropertyException.php b/src/JsonMapper/Exception/UnknownPropertyException.php index 2d81044..6661abd 100644 --- a/src/JsonMapper/Exception/UnknownPropertyException.php +++ b/src/JsonMapper/Exception/UnknownPropertyException.php @@ -18,6 +18,11 @@ */ final class UnknownPropertyException extends MappingException { + /** + * @param string $path Path to the JSON value that references the unknown property. + * @param string $propertyName Name of the property that does not exist on the PHP target. + * @param class-string $className Fully qualified name of the object that lacks the property. + */ public function __construct( string $path, private readonly string $propertyName, @@ -30,13 +35,25 @@ public function __construct( ); } + /** + * Returns the unknown property name as provided by the JSON payload. + * + * Callers can expose the value in validation errors so clients can remove + * unsupported fields. + * + * @return string Property name that could not be mapped. + */ public function getPropertyName(): string { return $this->propertyName; } /** - * @return class-string + * Provides the class for which the property is unknown. + * + * Consumers may use this to highlight which DTO rejected the incoming property. + * + * @return class-string Fully qualified class name without the referenced property. */ public function getClassName(): string { From 9bba24c19f2c04043528aefc6ae6770b00e6462e Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 10:58:18 +0100 Subject: [PATCH 38/46] docs(mapper): clarify JsonMapper PHPDoc blocks --- src/JsonMapper.php | 113 +++++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index f7360fe..1fec748 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -424,17 +424,24 @@ public function mapWithReport( return new MappingResult($value, new MappingReport($context->getErrorRecords())); } + /** + * Creates a clone of the default mapper configuration for a fresh mapping context. + * + * @return JsonMapperConfiguration Copy of the base configuration that can be mutated safely. + */ private function createDefaultConfiguration(): JsonMapperConfiguration { return clone $this->config; } /** - * @param class-string $className - * @param array $declaredProperties - * @param list $mappedProperties + * Identifies required properties that were not provided in the source data. + * + * @param class-string $className Fully qualified class name inspected for required metadata. + * @param array $declaredProperties List of property names resolved from the target class definition. + * @param list $mappedProperties List of properties that successfully received mapped values. * - * @return list + * @return list List of property names that are still required after mapping. */ private function determineMissingProperties(string $className, array $declaredProperties, array $mappedProperties): array { @@ -447,7 +454,12 @@ private function determineMissingProperties(string $className, array $declaredPr } /** - * @param class-string $className + * Determines whether the given property must be present on the input data. + * + * @param class-string $className Fully qualified class name whose property metadata is evaluated. + * @param string $propertyName Property name checked for default values and nullability. + * + * @return bool True when the property is mandatory and missing values must be reported. */ private function isRequiredProperty(string $className, string $propertyName): bool { @@ -480,10 +492,18 @@ private function isRequiredProperty(string $className, string $propertyName): bo return false; } + /** + * Records a mapping exception and decides whether it should stop the mapping process. + * + * @param MappingException $exception Exception that occurred while mapping a property. + * @param MappingContext $context Context collecting the error information. + * @param JsonMapperConfiguration $configuration Configuration that controls strict-mode behaviour. + */ private function handleMappingException(MappingException $exception, MappingContext $context, JsonMapperConfiguration $configuration): void { $context->recordException($exception); + // Strict mode propagates the failure immediately to abort mapping on the first error. if ($configuration->isStrictMode()) { throw $exception; } @@ -520,7 +540,11 @@ private function convertValue(mixed $json, Type $type, MappingContext $context): /** * Converts the value according to the provided union type. * - * @param UnionType $type + * @param mixed $json Value being converted so it matches one of the union candidates. + * @param UnionType $type Union definition listing acceptable target types. + * @param MappingContext $context Context used to track conversion errors while testing candidates. + * + * @return mixed Value converted to a type accepted by the union. */ private function convertUnionValue(mixed $json, UnionType $type, MappingContext $context): mixed { @@ -611,7 +635,9 @@ private function describeType(Type $type): string /** * Returns a textual representation of the union type. * - * @param UnionType $type + * @param UnionType $type Union type converted into a human-readable string. + * + * @return string Pipe-separated description of all candidate types. */ private function describeUnionType(UnionType $type): string { @@ -625,7 +651,11 @@ private function describeUnionType(UnionType $type): string } /** - * @param UnionType $type + * Checks whether the provided union type accepts null values. + * + * @param UnionType $type Union type inspected for a nullable member. + * + * @return bool True when null is part of the union definition. */ private function unionAllowsNull(UnionType $type): bool { @@ -638,6 +668,13 @@ private function unionAllowsNull(UnionType $type): bool return false; } + /** + * Checks whether the provided type explicitly represents the null value. + * + * @param Type $type Type information extracted for a property or union candidate. + * + * @return bool True when the type identifies the null built-in. + */ private function isNullType(Type $type): bool { return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; @@ -646,10 +683,10 @@ private function isNullType(Type $type): bool /** * Creates an instance of the given class name. * - * @param string $className - * @param mixed ...$constructorArguments + * @param string $className Fully qualified class name to instantiate. + * @param mixed ...$constructorArguments Arguments forwarded to the constructor of the class. * - * @return object + * @return object Newly created instance of the requested class. */ private function makeInstance(string $className, mixed ...$constructorArguments): object { @@ -657,12 +694,12 @@ private function makeInstance(string $className, mixed ...$constructorArguments) } /** - * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. - */ - /** - * Returns TRUE if the property contains an "ReplaceNullWithDefaultValue" annotation. + * Checks whether the property declares the ReplaceNullWithDefaultValue attribute. + * + * @param class-string $className Fully qualified class containing the property to inspect. + * @param string $propertyName Property name that may carry the attribute. * - * @param class-string $className + * @return bool True when null inputs should be replaced with the property's default value. */ private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool { @@ -676,11 +713,11 @@ private function isReplaceNullWithDefaultValueAnnotation(string $className, stri } /** - * Builds the map of properties replaced by the annotation. + * Builds the mapping of legacy property names to their replacements declared via attributes. * - * @param class-string $className + * @param class-string $className Fully qualified class inspected for ReplaceProperty attributes. * - * @return array + * @return array Map of original property names to their replacement names. */ private function buildReplacePropertyMap(string $className): array { @@ -702,7 +739,12 @@ private function buildReplacePropertyMap(string $className): array } /** - * @param class-string $attributeClass + * Checks whether the given property is marked with the specified attribute class. + * + * @param ReflectionProperty $property Property reflection inspected for attributes. + * @param class-string $attributeClass Attribute class name to look for on the property. + * + * @return bool True when at least one matching attribute is present. */ private function hasAttribute(ReflectionProperty $property, string $attributeClass): bool { @@ -712,7 +754,10 @@ private function hasAttribute(ReflectionProperty $property, string $attributeCla /** * Normalizes the property name using annotations and converters. * - * @param array $replacePropertyMap + * @param string|int $propertyName Property name taken from the source payload. + * @param array $replacePropertyMap Map of alias names to their replacement counterparts. + * + * @return string|int Normalized property name to use for mapping. */ private function normalizePropertyName(string|int $propertyName, array $replacePropertyMap): string|int { @@ -732,9 +777,9 @@ private function normalizePropertyName(string|int $propertyName, array $replaceP /** * Converts arrays and objects into a plain array structure. * - * @param array|object $json + * @param array|object $json Source payload that may be an array, object, or traversable. * - * @return array + * @return array Normalised array representation of the provided payload. */ private function toIterableArray(array|object $json): array { @@ -752,7 +797,10 @@ private function toIterableArray(array|object $json): array /** * Returns the specified reflection property. * - * @param class-string $className + * @param class-string $className Fully qualified class containing the property definition. + * @param string $propertyName Property name resolved on the reflected class. + * + * @return ReflectionProperty|null Reflection property instance when the property exists, null otherwise. */ private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty { @@ -766,9 +814,9 @@ private function getReflectionProperty(string $className, string $propertyName): /** * Returns the specified reflection class. * - * @param class-string $className + * @param class-string $className Fully qualified class name that should be reflected. * - * @return ReflectionClass|null + * @return ReflectionClass|null Reflection of the class when it exists, otherwise null. */ private function getReflectionClass(string $className): ?ReflectionClass { @@ -782,7 +830,10 @@ private function getReflectionClass(string $className): ?ReflectionClass /** * Returns the default value of a property. * - * @param class-string $className + * @param class-string $className Fully qualified class that defines the property. + * @param string $propertyName Property name whose default value should be retrieved. + * + * @return mixed Default value configured on the property, or null when none exists. */ private function getDefaultValue(string $className, string $propertyName): mixed { @@ -798,7 +849,9 @@ private function getDefaultValue(string $className, string $propertyName): mixed /** * Returns TRUE if the given JSON contains integer property keys. * - * @param array|object $json + * @param array|object $json Source payload inspected for numeric keys. + * + * @return bool True when at least one numeric index is present. */ private function isNumericIndexArray(array|object $json): bool { @@ -893,9 +946,9 @@ private function setProperty(object $entity, string $name, mixed $value, Mapping /** * Get all public properties for the specified class. * - * @param class-string $className + * @param class-string $className Fully qualified class whose property names should be extracted. * - * @return string[] + * @return string[] List of property names exposed by the configured extractor. */ private function getProperties(string $className): array { From 207f443c19a23eeae1961375b92eabdda8e19768 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 11:12:50 +0100 Subject: [PATCH 39/46] fix(value): document custom type aliases as non-empty --- src/JsonMapper.php | 2 +- src/JsonMapper/Value/ClosureTypeHandler.php | 2 +- src/JsonMapper/Value/CustomTypeRegistry.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 1fec748..2f89413 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -178,7 +178,7 @@ public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper /** * Registers a custom type using a closure-based handler. * - * @param string $type Name of the custom type alias handled by the closure. + * @param non-empty-string $type Name of the custom type alias handled by the closure. * @param Closure $closure Closure that converts the incoming value to the target type. * * @deprecated Use addTypeHandler() with a TypeHandlerInterface implementation instead. diff --git a/src/JsonMapper/Value/ClosureTypeHandler.php b/src/JsonMapper/Value/ClosureTypeHandler.php index 236ef30..fa03096 100644 --- a/src/JsonMapper/Value/ClosureTypeHandler.php +++ b/src/JsonMapper/Value/ClosureTypeHandler.php @@ -28,7 +28,7 @@ final class ClosureTypeHandler implements TypeHandlerInterface private Closure $converter; /** - * @param class-string $className Fully-qualified class name the handler is responsible for. + * @param non-empty-string $className Type alias handled by the converter. * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter Callable receiving the mapped value and * optionally the mapping context. */ diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index a8aaff4..91c24d6 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -30,7 +30,7 @@ final class CustomTypeRegistry /** * Registers the converter for the provided class name. * - * @param string $className Fully-qualified class name handled by the converter. + * @param non-empty-string $className Fully-qualified type alias handled by the converter. * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter Callback responsible for creating the destination value. * * @return void From 16075fb442fbd3417902da7a79de37d657c9c7bd Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 11:29:24 +0100 Subject: [PATCH 40/46] test(docs): cover README and recipe examples --- tests/Fixtures/Cache/InMemoryCacheItem.php | 62 +++++++++ tests/Fixtures/Cache/InMemoryCachePool.php | 125 ++++++++++++++++++ .../Converter/UpperSnakeCaseConverter.php | 22 +++ tests/Fixtures/Docs/NameConverter/Event.php | 17 +++ .../Docs/NestedCollections/Article.php | 20 +++ .../NestedCollections/ArticleCollection.php | 21 +++ .../NestedCollections/NestedTagCollection.php | 21 +++ tests/Fixtures/Docs/NestedCollections/Tag.php | 17 +++ .../Docs/NestedCollections/TagCollection.php | 21 +++ tests/Fixtures/Docs/QuickStart/Article.php | 22 +++ .../Docs/QuickStart/ArticleCollection.php | 21 +++ tests/Fixtures/Docs/QuickStart/Comment.php | 17 +++ .../Docs/QuickStart/CommentCollection.php | 21 +++ .../DocsCustomNameConverterTest.php | 44 ++++++ .../JsonMapper/DocsNestedCollectionsTest.php | 61 +++++++++ tests/JsonMapper/DocsQuickStartTest.php | 51 +++++++ tests/JsonMapper/JsonMapperTypeCacheTest.php | 53 ++++++++ 17 files changed, 616 insertions(+) create mode 100644 tests/Fixtures/Cache/InMemoryCacheItem.php create mode 100644 tests/Fixtures/Cache/InMemoryCachePool.php create mode 100644 tests/Fixtures/Converter/UpperSnakeCaseConverter.php create mode 100644 tests/Fixtures/Docs/NameConverter/Event.php create mode 100644 tests/Fixtures/Docs/NestedCollections/Article.php create mode 100644 tests/Fixtures/Docs/NestedCollections/ArticleCollection.php create mode 100644 tests/Fixtures/Docs/NestedCollections/NestedTagCollection.php create mode 100644 tests/Fixtures/Docs/NestedCollections/Tag.php create mode 100644 tests/Fixtures/Docs/NestedCollections/TagCollection.php create mode 100644 tests/Fixtures/Docs/QuickStart/Article.php create mode 100644 tests/Fixtures/Docs/QuickStart/ArticleCollection.php create mode 100644 tests/Fixtures/Docs/QuickStart/Comment.php create mode 100644 tests/Fixtures/Docs/QuickStart/CommentCollection.php create mode 100644 tests/JsonMapper/DocsCustomNameConverterTest.php create mode 100644 tests/JsonMapper/DocsNestedCollectionsTest.php create mode 100644 tests/JsonMapper/DocsQuickStartTest.php create mode 100644 tests/JsonMapper/JsonMapperTypeCacheTest.php diff --git a/tests/Fixtures/Cache/InMemoryCacheItem.php b/tests/Fixtures/Cache/InMemoryCacheItem.php new file mode 100644 index 0000000..b4fbce8 --- /dev/null +++ b/tests/Fixtures/Cache/InMemoryCacheItem.php @@ -0,0 +1,62 @@ +value = $value; + $this->hit = $hit; + } + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->value; + } + + public function isHit(): bool + { + return $this->hit; + } + + public function set(mixed $value): static + { + $this->value = $value; + $this->hit = true; + + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static + { + return $this; + } + + public function expiresAfter(DateInterval|int|null $time): static + { + return $this; + } +} diff --git a/tests/Fixtures/Cache/InMemoryCachePool.php b/tests/Fixtures/Cache/InMemoryCachePool.php new file mode 100644 index 0000000..5f38b19 --- /dev/null +++ b/tests/Fixtures/Cache/InMemoryCachePool.php @@ -0,0 +1,125 @@ + + */ + private array $items = []; + + private int $saveCalls = 0; + + private int $hitCount = 0; + + public function getItem(string $key): CacheItemInterface + { + if (isset($this->items[$key])) { + $item = $this->items[$key]; + + if ($item->isHit()) { + $this->hitCount++; + } + + return $item; + } + + $item = new InMemoryCacheItem($key); + $this->items[$key] = $item; + + return $item; + } + + public function getItems(array $keys = []): iterable + { + if ($keys === []) { + return $this->items; + } + + $result = []; + + foreach ($keys as $key) { + $result[$key] = $this->getItem($key); + } + + return $result; + } + + public function hasItem(string $key): bool + { + if (!isset($this->items[$key])) { + return false; + } + + return $this->items[$key]->isHit(); + } + + public function clear(): bool + { + $this->items = []; + $this->saveCalls = 0; + $this->hitCount = 0; + + return true; + } + + public function deleteItem(string $key): bool + { + unset($this->items[$key]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->items[$key]); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item instanceof InMemoryCacheItem + ? $item + : new InMemoryCacheItem($item->getKey(), $item->get(), $item->isHit()); + + $this->saveCalls++; + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function commit(): bool + { + return true; + } + + public function getSaveCalls(): int + { + return $this->saveCalls; + } + + public function getHitCount(): int + { + return $this->hitCount; + } +} diff --git a/tests/Fixtures/Converter/UpperSnakeCaseConverter.php b/tests/Fixtures/Converter/UpperSnakeCaseConverter.php new file mode 100644 index 0000000..47c47e5 --- /dev/null +++ b/tests/Fixtures/Converter/UpperSnakeCaseConverter.php @@ -0,0 +1,22 @@ +> + */ + public NestedTagCollection $tags; +} diff --git a/tests/Fixtures/Docs/NestedCollections/ArticleCollection.php b/tests/Fixtures/Docs/NestedCollections/ArticleCollection.php new file mode 100644 index 0000000..efeb402 --- /dev/null +++ b/tests/Fixtures/Docs/NestedCollections/ArticleCollection.php @@ -0,0 +1,21 @@ + + */ +final class ArticleCollection extends ArrayObject +{ +} diff --git a/tests/Fixtures/Docs/NestedCollections/NestedTagCollection.php b/tests/Fixtures/Docs/NestedCollections/NestedTagCollection.php new file mode 100644 index 0000000..61d50c7 --- /dev/null +++ b/tests/Fixtures/Docs/NestedCollections/NestedTagCollection.php @@ -0,0 +1,21 @@ + + */ +final class NestedTagCollection extends ArrayObject +{ +} diff --git a/tests/Fixtures/Docs/NestedCollections/Tag.php b/tests/Fixtures/Docs/NestedCollections/Tag.php new file mode 100644 index 0000000..48148d3 --- /dev/null +++ b/tests/Fixtures/Docs/NestedCollections/Tag.php @@ -0,0 +1,17 @@ + + */ +final class TagCollection extends ArrayObject +{ +} diff --git a/tests/Fixtures/Docs/QuickStart/Article.php b/tests/Fixtures/Docs/QuickStart/Article.php new file mode 100644 index 0000000..bd64cfe --- /dev/null +++ b/tests/Fixtures/Docs/QuickStart/Article.php @@ -0,0 +1,22 @@ + + */ + public CommentCollection $comments; +} diff --git a/tests/Fixtures/Docs/QuickStart/ArticleCollection.php b/tests/Fixtures/Docs/QuickStart/ArticleCollection.php new file mode 100644 index 0000000..fcdb6d5 --- /dev/null +++ b/tests/Fixtures/Docs/QuickStart/ArticleCollection.php @@ -0,0 +1,21 @@ + + */ +final class ArticleCollection extends ArrayObject +{ +} diff --git a/tests/Fixtures/Docs/QuickStart/Comment.php b/tests/Fixtures/Docs/QuickStart/Comment.php new file mode 100644 index 0000000..614e14f --- /dev/null +++ b/tests/Fixtures/Docs/QuickStart/Comment.php @@ -0,0 +1,17 @@ + + */ +final class CommentCollection extends ArrayObject +{ +} diff --git a/tests/JsonMapper/DocsCustomNameConverterTest.php b/tests/JsonMapper/DocsCustomNameConverterTest.php new file mode 100644 index 0000000..c5e4c70 --- /dev/null +++ b/tests/JsonMapper/DocsCustomNameConverterTest.php @@ -0,0 +1,44 @@ +getJsonAsObject('{"EVENT_CODE":"signup"}'); + + $event = $mapper->map($json, Event::class); + + self::assertInstanceOf(Event::class, $event); + self::assertSame('signup', $event->eventcode); + } +} diff --git a/tests/JsonMapper/DocsNestedCollectionsTest.php b/tests/JsonMapper/DocsNestedCollectionsTest.php new file mode 100644 index 0000000..396153d --- /dev/null +++ b/tests/JsonMapper/DocsNestedCollectionsTest.php @@ -0,0 +1,61 @@ +getJsonMapper(); + + $json = $this->getJsonAsObject('[ + { + "tags": [ + [{"name": "php"}], + [{"name": "json"}] + ] + } + ]'); + + $articles = $mapper->map($json, Article::class, ArticleCollection::class); + + self::assertInstanceOf(ArticleCollection::class, $articles); + self::assertCount(1, $articles); + self::assertContainsOnlyInstancesOf(Article::class, $articles); + + $article = $articles[0]; + self::assertInstanceOf(NestedTagCollection::class, $article->tags); + self::assertCount(2, $article->tags); + self::assertContainsOnlyInstancesOf(TagCollection::class, $article->tags); + + $firstRow = $article->tags[0]; + self::assertInstanceOf(TagCollection::class, $firstRow); + self::assertCount(1, $firstRow); + self::assertContainsOnlyInstancesOf(Tag::class, $firstRow); + self::assertSame('php', $firstRow[0]->name); + + $secondRow = $article->tags[1]; + self::assertInstanceOf(TagCollection::class, $secondRow); + self::assertCount(1, $secondRow); + self::assertContainsOnlyInstancesOf(Tag::class, $secondRow); + self::assertSame('json', $secondRow[0]->name); + } +} diff --git a/tests/JsonMapper/DocsQuickStartTest.php b/tests/JsonMapper/DocsQuickStartTest.php new file mode 100644 index 0000000..519f6f0 --- /dev/null +++ b/tests/JsonMapper/DocsQuickStartTest.php @@ -0,0 +1,51 @@ +getJsonMapper(); + + $single = $this->getJsonAsObject('{"title":"Hello world","comments":[{"message":"First!"}]}'); + $article = $mapper->map($single, Article::class); + + self::assertInstanceOf(Article::class, $article); + self::assertSame('Hello world', $article->title); + self::assertInstanceOf(CommentCollection::class, $article->comments); + self::assertCount(1, $article->comments); + self::assertContainsOnlyInstancesOf(Comment::class, $article->comments); + self::assertSame('First!', $article->comments[0]->message); + + $list = $this->getJsonAsObject('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]'); + $articles = $mapper->map($list, Article::class, ArticleCollection::class); + + self::assertInstanceOf(ArticleCollection::class, $articles); + self::assertCount(2, $articles); + self::assertContainsOnlyInstancesOf(Article::class, $articles); + self::assertSame('Hello world', $articles[0]->title); + self::assertSame('Second', $articles[1]->title); + self::assertInstanceOf(CommentCollection::class, $articles[0]->comments); + self::assertCount(1, $articles[0]->comments); + self::assertInstanceOf(CommentCollection::class, $articles[1]->comments); + self::assertCount(0, $articles[1]->comments); + } +} diff --git a/tests/JsonMapper/JsonMapperTypeCacheTest.php b/tests/JsonMapper/JsonMapperTypeCacheTest.php new file mode 100644 index 0000000..6400e62 --- /dev/null +++ b/tests/JsonMapper/JsonMapperTypeCacheTest.php @@ -0,0 +1,53 @@ +getJsonAsObject('{"title":"Cache","comments":[{"message":"hit"}]}'); + + $mapper->map($json, Article::class); + + $initialSaveCount = $cache->getSaveCalls(); + self::assertGreaterThan(0, $initialSaveCount); + self::assertSame(0, $cache->getHitCount()); + + $mapper->map($json, Article::class); + + self::assertSame($initialSaveCount, $cache->getSaveCalls()); + self::assertSame($initialSaveCount, $cache->getHitCount()); + } +} From 54fdd74b81680a78b16aec5d25351c5c7e3c0cce Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 11:43:35 +0100 Subject: [PATCH 41/46] fix(ci): satisfy composer ci:test --- src/JsonMapper.php | 30 ++++++------ src/JsonMapper/Attribute/ReplaceProperty.php | 2 +- .../CollectionDocBlockTypeResolver.php | 6 +-- .../Collection/CollectionFactory.php | 12 ++--- .../Collection/CollectionFactoryInterface.php | 12 ++--- .../Configuration/JsonMapperConfiguration.php | 14 +++--- src/JsonMapper/Context/MappingContext.php | 4 +- src/JsonMapper/Context/MappingError.php | 6 +-- src/JsonMapper/Exception/MappingException.php | 6 +-- .../Exception/MissingPropertyException.php | 6 +-- .../Exception/UnknownPropertyException.php | 6 +-- src/JsonMapper/Report/MappingResult.php | 2 +- src/JsonMapper/Resolver/ClassResolver.php | 14 +++--- src/JsonMapper/Type/TypeResolver.php | 8 ++-- src/JsonMapper/Value/ClosureTypeHandler.php | 2 +- src/JsonMapper/Value/CustomTypeRegistry.php | 8 ++-- .../BuiltinValueConversionStrategy.php | 20 ++++---- .../CollectionValueConversionStrategy.php | 8 ++-- .../CustomTypeValueConversionStrategy.php | 8 ++-- .../DateTimeValueConversionStrategy.php | 8 ++-- .../Strategy/EnumValueConversionStrategy.php | 8 ++-- .../Strategy/NullValueConversionStrategy.php | 8 ++-- .../ObjectTypeConversionGuardTrait.php | 12 ++--- .../ObjectValueConversionStrategy.php | 12 ++--- .../PassthroughValueConversionStrategy.php | 8 ++-- .../ValueConversionStrategyInterface.php | 8 ++-- src/JsonMapper/Value/ValueConverter.php | 4 +- tests/Fixtures/Cache/InMemoryCachePool.php | 15 ++++-- .../DocsCustomNameConverterTest.php | 2 +- .../JsonMapper/DocsNestedCollectionsTest.php | 29 ++++++++---- tests/JsonMapper/DocsQuickStartTest.php | 47 ++++++++++++++----- tests/JsonMapper/JsonMapperTypeCacheTest.php | 4 +- 32 files changed, 191 insertions(+), 148 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 2f89413..6110d2e 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -110,12 +110,12 @@ /** * Creates a mapper that converts JSON data into PHP objects using the configured Symfony services. * - * @param PropertyInfoExtractorInterface $extractor Extractor that provides type information for mapped properties. - * @param PropertyAccessorInterface $accessor Property accessor used to write values onto target objects. + * @param PropertyInfoExtractorInterface $extractor Extractor that provides type information for mapped properties. + * @param PropertyAccessorInterface $accessor Property accessor used to write values onto target objects. * @param PropertyNameConverterInterface|null $nameConverter Optional converter to normalise incoming property names. - * @param array $classMap Map of base classes to resolvers that determine the concrete class to instantiate. - * @param CacheItemPoolInterface|null $typeCache Optional cache for resolved type information. - * @param JsonMapperConfiguration $config Default mapper configuration cloned for new mapping contexts. + * @param array $classMap Map of base classes to resolvers that determine the concrete class to instantiate. + * @param CacheItemPoolInterface|null $typeCache Optional cache for resolved type information. + * @param JsonMapperConfiguration $config Default mapper configuration cloned for new mapping contexts. */ public function __construct( private PropertyInfoExtractorInterface $extractor, @@ -179,7 +179,7 @@ public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper * Registers a custom type using a closure-based handler. * * @param non-empty-string $type Name of the custom type alias handled by the closure. - * @param Closure $closure Closure that converts the incoming value to the target type. + * @param Closure $closure Closure that converts the incoming value to the target type. * * @deprecated Use addTypeHandler() with a TypeHandlerInterface implementation instead. * @@ -213,11 +213,11 @@ public function addCustomClassMapEntry(string $className, Closure $closure): Jso /** * Maps the JSON to the specified class entity. * - * @param mixed $json Source data to map into PHP objects. - * @param class-string|null $className Fully qualified class name that should be instantiated for mapped objects. - * @param class-string|null $collectionClassName Collection class that should wrap the mapped objects when required. - * @param MappingContext|null $context Optional mapping context reused across nested mappings. - * @param JsonMapperConfiguration|null $configuration Optional configuration that overrides the default mapper settings. + * @param mixed $json Source data to map into PHP objects. + * @param class-string|null $className Fully qualified class name that should be instantiated for mapped objects. + * @param class-string|null $collectionClassName Collection class that should wrap the mapped objects when required. + * @param MappingContext|null $context Optional mapping context reused across nested mappings. + * @param JsonMapperConfiguration|null $configuration Optional configuration that overrides the default mapper settings. * * @return mixed The mapped PHP value or collection produced from the given JSON. */ @@ -540,9 +540,9 @@ private function convertValue(mixed $json, Type $type, MappingContext $context): /** * Converts the value according to the provided union type. * - * @param mixed $json Value being converted so it matches one of the union candidates. - * @param UnionType $type Union definition listing acceptable target types. - * @param MappingContext $context Context used to track conversion errors while testing candidates. + * @param mixed $json Value being converted so it matches one of the union candidates. + * @param UnionType $type Union definition listing acceptable target types. + * @param MappingContext $context Context used to track conversion errors while testing candidates. * * @return mixed Value converted to a type accepted by the union. */ @@ -683,7 +683,7 @@ private function isNullType(Type $type): bool /** * Creates an instance of the given class name. * - * @param string $className Fully qualified class name to instantiate. + * @param string $className Fully qualified class name to instantiate. * @param mixed ...$constructorArguments Arguments forwarded to the constructor of the class. * * @return object Newly created instance of the requested class. diff --git a/src/JsonMapper/Attribute/ReplaceProperty.php b/src/JsonMapper/Attribute/ReplaceProperty.php index 8263b28..0625434 100644 --- a/src/JsonMapper/Attribute/ReplaceProperty.php +++ b/src/JsonMapper/Attribute/ReplaceProperty.php @@ -20,7 +20,7 @@ final readonly class ReplaceProperty { /** - * @param string $value Name of the incoming JSON field that should be renamed. + * @param string $value Name of the incoming JSON field that should be renamed. * @param string $replaces Target property name that receives the value. */ public function __construct( diff --git a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php index 3afd078..dfb66d3 100644 --- a/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php +++ b/src/JsonMapper/Collection/CollectionDocBlockTypeResolver.php @@ -37,9 +37,9 @@ final class CollectionDocBlockTypeResolver private DocBlockFactoryInterface $docBlockFactory; /** - * @param DocBlockFactoryInterface|null $docBlockFactory Optional docblock factory used to parse collection annotations. - * @param ContextFactory $contextFactory Factory for building type resolution contexts for reflected classes. - * @param PhpDocTypeHelper $phpDocTypeHelper Helper translating DocBlock types into Symfony TypeInfo representations. + * @param DocBlockFactoryInterface|null $docBlockFactory Optional docblock factory used to parse collection annotations. + * @param ContextFactory $contextFactory Factory for building type resolution contexts for reflected classes. + * @param PhpDocTypeHelper $phpDocTypeHelper Helper translating DocBlock types into Symfony TypeInfo representations. */ public function __construct( ?DocBlockFactoryInterface $docBlockFactory = null, diff --git a/src/JsonMapper/Collection/CollectionFactory.php b/src/JsonMapper/Collection/CollectionFactory.php index e0b8496..47c78a8 100644 --- a/src/JsonMapper/Collection/CollectionFactory.php +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -54,9 +54,9 @@ public function __construct( /** * Converts the provided iterable JSON structure to a PHP array. * - * @param mixed $json Raw JSON data representing the collection to hydrate. - * @param Type $valueType Type descriptor for individual collection entries. - * @param MappingContext $context Active mapping context providing path and strictness information. + * @param mixed $json Raw JSON data representing the collection to hydrate. + * @param Type $valueType Type descriptor for individual collection entries. + * @param MappingContext $context Active mapping context providing path and strictness information. * * @return array|null Normalised collection data or null when conversion fails. */ @@ -100,9 +100,9 @@ public function mapIterable(mixed $json, Type $valueType, MappingContext $contex /** * Builds a collection based on the specified collection type description. * - * @param CollectionType> $type Resolved collection metadata from docblocks or attributes. - * @param mixed $json Raw JSON payload containing the collection values. - * @param MappingContext $context Mapping context controlling strict mode and error tracking. + * @param CollectionType> $type Resolved collection metadata from docblocks or attributes. + * @param mixed $json Raw JSON payload containing the collection values. + * @param MappingContext $context Mapping context controlling strict mode and error tracking. * * @return object|array|null Instantiated collection wrapper or the normalised array values. */ diff --git a/src/JsonMapper/Collection/CollectionFactoryInterface.php b/src/JsonMapper/Collection/CollectionFactoryInterface.php index 1ca8fbf..7bcb51d 100644 --- a/src/JsonMapper/Collection/CollectionFactoryInterface.php +++ b/src/JsonMapper/Collection/CollectionFactoryInterface.php @@ -32,9 +32,9 @@ interface CollectionFactoryInterface /** * Converts the provided iterable JSON structure to a PHP array. * - * @param mixed $json Raw JSON data representing the iterable input to normalise. - * @param Type $valueType Type description for the collection values. - * @param MappingContext $context Active mapping context carrying strictness and error reporting configuration. + * @param mixed $json Raw JSON data representing the iterable input to normalise. + * @param Type $valueType Type description for the collection values. + * @param MappingContext $context Active mapping context carrying strictness and error reporting configuration. * * @return array|null Normalised array representation or null when conversion fails. */ @@ -43,9 +43,9 @@ public function mapIterable(mixed $json, Type $valueType, MappingContext $contex /** * Builds a collection based on the specified collection type description. * - * @param CollectionType> $type Resolved collection metadata from PHPDoc or attributes. - * @param mixed $json Raw JSON payload containing the collection values. - * @param MappingContext $context Mapping context controlling strict mode and error recording. + * @param CollectionType> $type Resolved collection metadata from PHPDoc or attributes. + * @param mixed $json Raw JSON payload containing the collection values. + * @param MappingContext $context Mapping context controlling strict mode and error recording. * * @return array|object|null Instantiated collection wrapper or the normalised array values. */ diff --git a/src/JsonMapper/Configuration/JsonMapperConfiguration.php b/src/JsonMapper/Configuration/JsonMapperConfiguration.php index a240670..c29da28 100644 --- a/src/JsonMapper/Configuration/JsonMapperConfiguration.php +++ b/src/JsonMapper/Configuration/JsonMapperConfiguration.php @@ -24,13 +24,13 @@ final class JsonMapperConfiguration /** * Creates a new configuration instance with optional overrides. * - * @param bool $strictMode Whether unknown/missing properties should trigger errors - * @param bool $collectErrors Whether encountered mapping errors should be collected - * @param bool $emptyStringIsNull Whether empty strings are converted to null - * @param bool $ignoreUnknownProperties Whether properties missing in the destination type are ignored - * @param bool $treatNullAsEmptyCollection Whether null collections are replaced with empty collections - * @param string $defaultDateFormat Default `DateTimeInterface` format used for serialization/deserialization - * @param bool $allowScalarToObjectCasting Whether scalars can be coerced into objects when supported + * @param bool $strictMode Whether unknown/missing properties should trigger errors + * @param bool $collectErrors Whether encountered mapping errors should be collected + * @param bool $emptyStringIsNull Whether empty strings are converted to null + * @param bool $ignoreUnknownProperties Whether properties missing in the destination type are ignored + * @param bool $treatNullAsEmptyCollection Whether null collections are replaced with empty collections + * @param string $defaultDateFormat Default `DateTimeInterface` format used for serialization/deserialization + * @param bool $allowScalarToObjectCasting Whether scalars can be coerced into objects when supported */ public function __construct( private bool $strictMode = false, diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php index e163424..fc7f7c0 100644 --- a/src/JsonMapper/Context/MappingContext.php +++ b/src/JsonMapper/Context/MappingContext.php @@ -108,8 +108,8 @@ public function withPathSegment(string|int $segment, callable $callback): mixed /** * Stores the error message for later consumption. * - * @param string $message Human-readable description of the failure - * @param MappingException|null $exception Optional exception associated with the failure + * @param string $message Human-readable description of the failure + * @param MappingException|null $exception Optional exception associated with the failure * * @return void */ diff --git a/src/JsonMapper/Context/MappingError.php b/src/JsonMapper/Context/MappingError.php index 6aa9065..24b9e49 100644 --- a/src/JsonMapper/Context/MappingError.php +++ b/src/JsonMapper/Context/MappingError.php @@ -19,9 +19,9 @@ final readonly class MappingError { /** - * @param string $path JSON path pointing to the failing property - * @param string $message Human-readable description of the failure - * @param MappingException|null $exception Exception that triggered the error, when available + * @param string $path JSON path pointing to the failing property + * @param string $message Human-readable description of the failure + * @param MappingException|null $exception Exception that triggered the error, when available */ public function __construct( private string $path, diff --git a/src/JsonMapper/Exception/MappingException.php b/src/JsonMapper/Exception/MappingException.php index 4931852..f79be6b 100644 --- a/src/JsonMapper/Exception/MappingException.php +++ b/src/JsonMapper/Exception/MappingException.php @@ -20,9 +20,9 @@ abstract class MappingException extends RuntimeException { /** - * @param string $message Human readable description of the failure scenario. - * @param string $path JSON pointer or dotted path identifying the failing value. - * @param int $code Optional error code to bubble up to the caller. + * @param string $message Human readable description of the failure scenario. + * @param string $path JSON pointer or dotted path identifying the failing value. + * @param int $code Optional error code to bubble up to the caller. * @param Throwable|null $previous Underlying cause, if the exception wraps another failure. */ public function __construct( diff --git a/src/JsonMapper/Exception/MissingPropertyException.php b/src/JsonMapper/Exception/MissingPropertyException.php index 8e3a72e..d6d1b99 100644 --- a/src/JsonMapper/Exception/MissingPropertyException.php +++ b/src/JsonMapper/Exception/MissingPropertyException.php @@ -19,9 +19,9 @@ final class MissingPropertyException extends MappingException { /** - * @param string $path Path indicating where the missing property should have been present. - * @param string $propertyName Name of the required property defined on the PHP target. - * @param class-string $className Fully qualified name of the DTO or object declaring the property. + * @param string $path Path indicating where the missing property should have been present. + * @param string $propertyName Name of the required property defined on the PHP target. + * @param class-string $className Fully qualified name of the DTO or object declaring the property. */ public function __construct( string $path, diff --git a/src/JsonMapper/Exception/UnknownPropertyException.php b/src/JsonMapper/Exception/UnknownPropertyException.php index 6661abd..0564f1d 100644 --- a/src/JsonMapper/Exception/UnknownPropertyException.php +++ b/src/JsonMapper/Exception/UnknownPropertyException.php @@ -19,9 +19,9 @@ final class UnknownPropertyException extends MappingException { /** - * @param string $path Path to the JSON value that references the unknown property. - * @param string $propertyName Name of the property that does not exist on the PHP target. - * @param class-string $className Fully qualified name of the object that lacks the property. + * @param string $path Path to the JSON value that references the unknown property. + * @param string $propertyName Name of the property that does not exist on the PHP target. + * @param class-string $className Fully qualified name of the object that lacks the property. */ public function __construct( string $path, diff --git a/src/JsonMapper/Report/MappingResult.php b/src/JsonMapper/Report/MappingResult.php index 1b62d67..b0e760d 100644 --- a/src/JsonMapper/Report/MappingResult.php +++ b/src/JsonMapper/Report/MappingResult.php @@ -17,7 +17,7 @@ final readonly class MappingResult { /** - * @param mixed $value The mapped value returned by the mapper. + * @param mixed $value The mapped value returned by the mapper. * @param MappingReport $report Report containing diagnostics for the mapping operation. */ public function __construct( diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php index e39dbd4..465279d 100644 --- a/src/JsonMapper/Resolver/ClassResolver.php +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -48,8 +48,8 @@ public function __construct(array $classMap = []) /** * Adds a custom resolution rule. * - * @param class-string $className Base class or interface the resolver handles. - * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver Callback returning a concrete class based on the JSON payload and optional mapping context. + * @param class-string $className Base class or interface the resolver handles. + * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver Callback returning a concrete class based on the JSON payload and optional mapping context. * * @phpstan-param class-string $className * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver @@ -63,9 +63,9 @@ public function add(string $className, Closure $resolver): void /** * Resolves the class name for the provided JSON payload. * - * @param class-string $className Base class name configured in the resolver map. - * @param mixed $json Raw JSON fragment inspected to determine the target class. - * @param MappingContext $context Mapping context passed to resolution callbacks when required. + * @param class-string $className Base class name configured in the resolver map. + * @param mixed $json Raw JSON fragment inspected to determine the target class. + * @param MappingContext $context Mapping context passed to resolution callbacks when required. * * @return class-string Fully-qualified class name that should be instantiated for the payload. */ @@ -100,8 +100,8 @@ public function resolve(string $className, mixed $json, MappingContext $context) * Executes a resolver callback while adapting the invocation to its declared arity. * * @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver User-provided resolver that determines the concrete class; the parameter list defines whether the mapping context can be injected. - * @param mixed $json JSON fragment forwarded to the resolver so it can inspect discriminator values. - * @param MappingContext $context Context object passed when supported to supply additional mapping metadata. + * @param mixed $json JSON fragment forwarded to the resolver so it can inspect discriminator values. + * @param MappingContext $context Context object passed when supported to supply additional mapping metadata. * * @return mixed Raw resolver result that will subsequently be validated as a class-string. */ diff --git a/src/JsonMapper/Type/TypeResolver.php b/src/JsonMapper/Type/TypeResolver.php index 87e7a47..3866a11 100644 --- a/src/JsonMapper/Type/TypeResolver.php +++ b/src/JsonMapper/Type/TypeResolver.php @@ -156,8 +156,8 @@ private function buildCacheKey(string $className, string $propertyName): string /** * Falls back to native reflection when PropertyInfo does not expose metadata for a property. * - * @param class-string $className Declaring class inspected via reflection; invalid classes yield null. - * @param string $propertyName Name of the property to inspect; missing properties short-circuit to null. + * @param class-string $className Declaring class inspected via reflection; invalid classes yield null. + * @param string $propertyName Name of the property to inspect; missing properties short-circuit to null. * * @return Type|null Type derived from the reflected signature, including nullability, or null when no type hint exists. */ @@ -216,8 +216,8 @@ private function resolveFromReflection(string $className, string $propertyName): /** * Translates a reflected named type into the internal Type representation while preserving nullability. * - * @param ReflectionNamedType $type Native type declaration; builtin names map to builtin identifiers, class names to object types. - * @param bool|null $nullable Overrides the reflection nullability flag when provided; null defers to the reflection metadata. + * @param ReflectionNamedType $type Native type declaration; builtin names map to builtin identifiers, class names to object types. + * @param bool|null $nullable Overrides the reflection nullability flag when provided; null defers to the reflection metadata. * * @return Type|null Resolved Type instance or null when the builtin name is unsupported. */ diff --git a/src/JsonMapper/Value/ClosureTypeHandler.php b/src/JsonMapper/Value/ClosureTypeHandler.php index fa03096..e47ff76 100644 --- a/src/JsonMapper/Value/ClosureTypeHandler.php +++ b/src/JsonMapper/Value/ClosureTypeHandler.php @@ -28,7 +28,7 @@ final class ClosureTypeHandler implements TypeHandlerInterface private Closure $converter; /** - * @param non-empty-string $className Type alias handled by the converter. + * @param non-empty-string $className Type alias handled by the converter. * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter Callable receiving the mapped value and * optionally the mapping context. */ diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php index 91c24d6..8abbf4e 100644 --- a/src/JsonMapper/Value/CustomTypeRegistry.php +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -30,7 +30,7 @@ final class CustomTypeRegistry /** * Registers the converter for the provided class name. * - * @param non-empty-string $className Fully-qualified type alias handled by the converter. + * @param non-empty-string $className Fully-qualified type alias handled by the converter. * @param callable(mixed):mixed|callable(mixed, MappingContext):mixed $converter Callback responsible for creating the destination value. * * @return void @@ -55,7 +55,7 @@ public function registerHandler(TypeHandlerInterface $handler): void /** * Returns TRUE if a handler for the type exists. * - * @param Type $type Type information describing the target property. + * @param Type $type Type information describing the target property. * @param mixed $value JSON value that should be converted. * * @return bool TRUE when at least one registered handler supports the value. @@ -74,8 +74,8 @@ public function supports(Type $type, mixed $value): bool /** * Executes the converter for the class. * - * @param Type $type Type information describing the target property. - * @param mixed $value JSON value that should be converted. + * @param Type $type Type information describing the target property. + * @param mixed $value JSON value that should be converted. * @param MappingContext $context Mapping context providing runtime configuration and state. * * @return mixed Converted value returned by the first supporting handler. diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php index 2556fcb..4071d39 100644 --- a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -43,8 +43,8 @@ final class BuiltinValueConversionStrategy implements ValueConversionStrategyInt /** * Determines whether the provided type represents a builtin PHP value. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the target type is a builtin PHP type. @@ -57,8 +57,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Converts the provided value to the builtin type defined by the metadata. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Value cast to the requested builtin type when possible. @@ -84,8 +84,8 @@ public function convert(mixed $value, Type $type, MappingContext $context): mixe /** * Normalizes common scalar representations before the conversion happens. * - * @param mixed $value Raw value coming from the input payload. - * @param BuiltinType $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param BuiltinType $type Type metadata describing the target property. * * @return mixed Normalized value that is compatible with the builtin type conversion. */ @@ -151,9 +151,9 @@ private function normalizeValue(mixed $value, BuiltinType $type): mixed /** * Validates that the value matches the builtin type or records a mismatch. * - * @param mixed $value Normalized value used during conversion. - * @param BuiltinType $type Type metadata describing the target property. - * @param MappingContext $context Mapping context providing configuration such as strict mode. + * @param mixed $value Normalized value used during conversion. + * @param BuiltinType $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return void */ @@ -203,7 +203,7 @@ private function allowsNull(BuiltinType $type): bool /** * Checks whether the value matches the builtin type identifier. * - * @param mixed $value Normalized value used during conversion. + * @param mixed $value Normalized value used during conversion. * @param TypeIdentifier $identifier Identifier of the builtin type to check against. * * @return bool TRUE when the value matches the identifier requirements. diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php index f3f7e05..789b954 100644 --- a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -36,8 +36,8 @@ public function __construct( /** * Determines whether the supplied type represents a collection. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the target type is a collection type. @@ -50,8 +50,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Converts the JSON value into a collection instance. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Collection created by the factory based on the type metadata. diff --git a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php index 5093d59..9ab0bc7 100644 --- a/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -33,8 +33,8 @@ public function __construct( /** * Determines whether the registry can handle the provided type. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the registry has a matching custom handler. @@ -47,8 +47,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Converts the value using the registered handler. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Value produced by the registered custom handler. diff --git a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php index 8c54049..dc771a5 100644 --- a/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -35,8 +35,8 @@ final class DateTimeValueConversionStrategy implements ValueConversionStrategyIn /** * Determines whether the requested type is a supported date or interval class. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the type represents a supported date/time object. @@ -57,8 +57,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Converts ISO-8601 strings and timestamps into the desired date/time object. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Instance of the configured date/time class. diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php index db4b1c6..03dc169 100644 --- a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -34,8 +34,8 @@ final class EnumValueConversionStrategy implements ValueConversionStrategyInterf /** * Determines whether the provided type is a backed enum. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the target type resolves to a backed enum. @@ -60,8 +60,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Converts the JSON scalar into the matching enum case. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Backed enum instance returned by the case factory method. diff --git a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php index 535a4d0..0e98065 100644 --- a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php @@ -22,8 +22,8 @@ final class NullValueConversionStrategy implements ValueConversionStrategyInterf /** * Determines whether the incoming value represents a null assignment. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the value is exactly null. @@ -36,8 +36,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Returns null to preserve the absence of a value. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return null Always returns null for supported values. diff --git a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php index 4633f0b..82a3860 100644 --- a/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php +++ b/src/JsonMapper/Value/Strategy/ObjectTypeConversionGuardTrait.php @@ -44,9 +44,9 @@ private function extractObjectType(Type $type): ?ObjectType /** * Ensures null values comply with the target object's nullability. * - * @param mixed $value Raw value coming from the input payload. - * @param ObjectType $type Object type metadata describing the target property. - * @param MappingContext $context Mapping context providing configuration such as strict mode. + * @param mixed $value Raw value coming from the input payload. + * @param ObjectType $type Object type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return void */ @@ -66,9 +66,9 @@ private function guardNullableValue(mixed $value, ObjectType $type, MappingConte /** * Executes the provided converter when a valid object type is available. * - * @param Type $type Type metadata describing the target property. - * @param MappingContext $context Mapping context providing configuration such as strict mode. - * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. + * @param MappingContext $context Mapping context providing configuration such as strict mode. + * @param mixed $value Raw value coming from the input payload. * @param callable(string, mixed): mixed $converter Callback that performs the actual conversion when a class-string is available. * * @return mixed Result from the converter or the original value when no object type was detected. diff --git a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php index 414e1cd..de32da8 100644 --- a/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -31,8 +31,8 @@ /** * Creates the strategy with the class resolver and mapper callback. * - * @param ClassResolver $classResolver Resolver used to select the concrete class to instantiate. - * @param Closure(mixed, class-string, MappingContext):mixed $mapper Callback responsible for mapping values into objects. + * @param ClassResolver $classResolver Resolver used to select the concrete class to instantiate. + * @param Closure(mixed, class-string, MappingContext):mixed $mapper Callback responsible for mapping values into objects. */ public function __construct( private ClassResolver $classResolver, @@ -43,8 +43,8 @@ public function __construct( /** * Determines whether the metadata describes an object type. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the target type represents an object. @@ -57,8 +57,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Delegates conversion to the mapper for the resolved class. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Value returned by the mapper callback. diff --git a/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php index f5ddf06..c6801d6 100644 --- a/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php +++ b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php @@ -22,8 +22,8 @@ final class PassthroughValueConversionStrategy implements ValueConversionStrateg /** * Always supports conversion and acts as the terminal strategy. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool Always TRUE so the strategy can act as the final fallback. @@ -36,8 +36,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Returns the original value without modification. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Unmodified value passed through from the input. diff --git a/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php b/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php index f37020d..63aeddc 100644 --- a/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php +++ b/src/JsonMapper/Value/Strategy/ValueConversionStrategyInterface.php @@ -22,8 +22,8 @@ interface ValueConversionStrategyInterface /** * Determines whether the strategy can convert the provided value for the requested type. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return bool TRUE when the strategy should perform the conversion. @@ -33,8 +33,8 @@ public function supports(mixed $value, Type $type, MappingContext $context): boo /** * Converts the value into a representation compatible with the requested type. * - * @param mixed $value Raw value coming from the input payload. - * @param Type $type Type metadata describing the target property. + * @param mixed $value Raw value coming from the input payload. + * @param Type $type Type metadata describing the target property. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Result of the conversion when the strategy supports the value. diff --git a/src/JsonMapper/Value/ValueConverter.php b/src/JsonMapper/Value/ValueConverter.php index 0f68da6..58a429d 100644 --- a/src/JsonMapper/Value/ValueConverter.php +++ b/src/JsonMapper/Value/ValueConverter.php @@ -43,8 +43,8 @@ public function addStrategy(ValueConversionStrategyInterface $strategy): void /** * Converts the value using the first matching strategy. * - * @param mixed $value Raw JSON value that needs to be converted. - * @param Type $type Target type metadata that should be satisfied by the conversion result. + * @param mixed $value Raw JSON value that needs to be converted. + * @param Type $type Target type metadata that should be satisfied by the conversion result. * @param MappingContext $context Mapping context providing configuration such as strict mode. * * @return mixed Result from the first strategy that declares support for the value. diff --git a/tests/Fixtures/Cache/InMemoryCachePool.php b/tests/Fixtures/Cache/InMemoryCachePool.php index 5f38b19..51b0901 100644 --- a/tests/Fixtures/Cache/InMemoryCachePool.php +++ b/tests/Fixtures/Cache/InMemoryCachePool.php @@ -25,30 +25,39 @@ final class InMemoryCachePool implements CacheItemPoolInterface private int $hitCount = 0; + /** + * @return InMemoryCacheItem + */ public function getItem(string $key): CacheItemInterface { if (isset($this->items[$key])) { $item = $this->items[$key]; if ($item->isHit()) { - $this->hitCount++; + ++$this->hitCount; } return $item; } - $item = new InMemoryCacheItem($key); + $item = new InMemoryCacheItem($key); $this->items[$key] = $item; return $item; } + /** + * @param array $keys + * + * @return iterable + */ public function getItems(array $keys = []): iterable { if ($keys === []) { return $this->items; } + /** @var array $result */ $result = []; foreach ($keys as $key) { @@ -98,7 +107,7 @@ public function save(CacheItemInterface $item): bool ? $item : new InMemoryCacheItem($item->getKey(), $item->get(), $item->isHit()); - $this->saveCalls++; + ++$this->saveCalls; return true; } diff --git a/tests/JsonMapper/DocsCustomNameConverterTest.php b/tests/JsonMapper/DocsCustomNameConverterTest.php index c5e4c70..91f0d25 100644 --- a/tests/JsonMapper/DocsCustomNameConverterTest.php +++ b/tests/JsonMapper/DocsCustomNameConverterTest.php @@ -28,7 +28,7 @@ public function itMapsUsingTheCustomNameConverterRecipe(): void { $converter = new UpperSnakeCaseConverter(); $extractor = new PropertyInfoExtractor([new ReflectionExtractor()], [new PhpDocExtractor()]); - $mapper = new JsonMapper( + $mapper = new JsonMapper( $extractor, PropertyAccess::createPropertyAccessor(), $converter, diff --git a/tests/JsonMapper/DocsNestedCollectionsTest.php b/tests/JsonMapper/DocsNestedCollectionsTest.php index 396153d..5af7091 100644 --- a/tests/JsonMapper/DocsNestedCollectionsTest.php +++ b/tests/JsonMapper/DocsNestedCollectionsTest.php @@ -39,23 +39,36 @@ public function itMapsTheNestedCollectionsRecipe(): void self::assertInstanceOf(ArticleCollection::class, $articles); self::assertCount(1, $articles); - self::assertContainsOnlyInstancesOf(Article::class, $articles); + self::assertTrue($articles->offsetExists(0)); $article = $articles[0]; - self::assertInstanceOf(NestedTagCollection::class, $article->tags); - self::assertCount(2, $article->tags); - self::assertContainsOnlyInstancesOf(TagCollection::class, $article->tags); + self::assertInstanceOf(Article::class, $article); - $firstRow = $article->tags[0]; + /** @var NestedTagCollection> $tags */ + $tags = $article->tags; + self::assertCount(2, $tags); + self::assertContainsOnlyInstancesOf(TagCollection::class, $tags); + + self::assertTrue($tags->offsetExists(0)); + $firstRow = $tags[0]; self::assertInstanceOf(TagCollection::class, $firstRow); self::assertCount(1, $firstRow); self::assertContainsOnlyInstancesOf(Tag::class, $firstRow); - self::assertSame('php', $firstRow[0]->name); + self::assertTrue($firstRow->offsetExists(0)); + + $firstTag = $firstRow[0]; + self::assertInstanceOf(Tag::class, $firstTag); + self::assertSame('php', $firstTag->name); - $secondRow = $article->tags[1]; + self::assertTrue($tags->offsetExists(1)); + $secondRow = $tags[1]; self::assertInstanceOf(TagCollection::class, $secondRow); self::assertCount(1, $secondRow); self::assertContainsOnlyInstancesOf(Tag::class, $secondRow); - self::assertSame('json', $secondRow[0]->name); + self::assertTrue($secondRow->offsetExists(0)); + + $secondTag = $secondRow[0]; + self::assertInstanceOf(Tag::class, $secondTag); + self::assertSame('json', $secondTag->name); } } diff --git a/tests/JsonMapper/DocsQuickStartTest.php b/tests/JsonMapper/DocsQuickStartTest.php index 519f6f0..bd89f99 100644 --- a/tests/JsonMapper/DocsQuickStartTest.php +++ b/tests/JsonMapper/DocsQuickStartTest.php @@ -25,27 +25,48 @@ public function itMapsTheReadmeQuickStartExample(): void { $mapper = $this->getJsonMapper(); - $single = $this->getJsonAsObject('{"title":"Hello world","comments":[{"message":"First!"}]}'); + $single = $this->getJsonAsObject('{"title":"Hello world","comments":[{"message":"First!"}]}'); $article = $mapper->map($single, Article::class); self::assertInstanceOf(Article::class, $article); self::assertSame('Hello world', $article->title); - self::assertInstanceOf(CommentCollection::class, $article->comments); - self::assertCount(1, $article->comments); - self::assertContainsOnlyInstancesOf(Comment::class, $article->comments); - self::assertSame('First!', $article->comments[0]->message); - $list = $this->getJsonAsObject('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]'); + /** @var CommentCollection $comments */ + $comments = $article->comments; + self::assertCount(1, $comments); + self::assertTrue($comments->offsetExists(0)); + + $firstComment = $comments[0]; + self::assertInstanceOf(Comment::class, $firstComment); + self::assertSame('First!', $firstComment->message); + + $list = $this->getJsonAsObject('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]'); $articles = $mapper->map($list, Article::class, ArticleCollection::class); self::assertInstanceOf(ArticleCollection::class, $articles); self::assertCount(2, $articles); - self::assertContainsOnlyInstancesOf(Article::class, $articles); - self::assertSame('Hello world', $articles[0]->title); - self::assertSame('Second', $articles[1]->title); - self::assertInstanceOf(CommentCollection::class, $articles[0]->comments); - self::assertCount(1, $articles[0]->comments); - self::assertInstanceOf(CommentCollection::class, $articles[1]->comments); - self::assertCount(0, $articles[1]->comments); + self::assertTrue($articles->offsetExists(0)); + self::assertTrue($articles->offsetExists(1)); + + $firstArticle = $articles[0]; + self::assertInstanceOf(Article::class, $firstArticle); + self::assertSame('Hello world', $firstArticle->title); + + /** @var CommentCollection $firstArticleComments */ + $firstArticleComments = $firstArticle->comments; + self::assertCount(1, $firstArticleComments); + self::assertTrue($firstArticleComments->offsetExists(0)); + + $firstArticleComment = $firstArticleComments[0]; + self::assertInstanceOf(Comment::class, $firstArticleComment); + self::assertSame('First!', $firstArticleComment->message); + + $secondArticle = $articles[1]; + self::assertInstanceOf(Article::class, $secondArticle); + self::assertSame('Second', $secondArticle->title); + + /** @var CommentCollection $secondArticleComments */ + $secondArticleComments = $secondArticle->comments; + self::assertCount(0, $secondArticleComments); } } diff --git a/tests/JsonMapper/JsonMapperTypeCacheTest.php b/tests/JsonMapper/JsonMapperTypeCacheTest.php index 6400e62..7768893 100644 --- a/tests/JsonMapper/JsonMapperTypeCacheTest.php +++ b/tests/JsonMapper/JsonMapperTypeCacheTest.php @@ -27,9 +27,9 @@ final class JsonMapperTypeCacheTest extends TestCase #[Test] public function itCachesResolvedTypesWhenConfigured(): void { - $cache = new InMemoryCachePool(); + $cache = new InMemoryCachePool(); $extractor = new PropertyInfoExtractor([new ReflectionExtractor()], [new PhpDocExtractor()]); - $mapper = new JsonMapper( + $mapper = new JsonMapper( $extractor, PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), From 6146c7d26efec03c085a0b5a921b71162bfc5b0b Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 11:57:08 +0100 Subject: [PATCH 42/46] docs: align documentation examples with tests --- README.md | 22 ++++++++++++++-------- docs/API.md | 4 ++-- docs/recipes/custom-name-converter.md | 10 +++++----- docs/recipes/mapping-with-enums.md | 11 ++++++----- docs/recipes/nested-collections.md | 5 ++++- docs/recipes/using-attributes.md | 14 +++++++++++--- 6 files changed, 42 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8725dc6..d25ab76 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ final class ArticleCollection extends ArrayObject final class Article { public string $title; + + /** + * @var CommentCollection + */ public CommentCollection $comments; } ``` @@ -60,7 +64,7 @@ require __DIR__ . '/vendor/autoload.php'; use App\Dto\Article; use App\Dto\ArticleCollection; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -89,6 +93,8 @@ var_dump($article, $articles); The first call produces an `Article` instance with a populated `CommentCollection`; the second call returns an `ArticleCollection` containing `Article` objects. +Test coverage: `tests/JsonMapper/DocsQuickStartTest.php`. + ### PHP classes In order to guarantee a seamless mapping of a JSON response into PHP classes you should prepare your classes well. Annotate all properties with the requested type. @@ -170,7 +176,7 @@ To use the `PhpDocExtractor` extractor you need to install the `phpdocumentor/re require __DIR__ . '/vendor/autoload.php'; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -219,7 +225,7 @@ You may alternatively implement `\MagicSunday\JsonMapper\Value\TypeHandlerInterf require __DIR__ . '/vendor/autoload.php'; use DateTimeImmutable; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use MagicSunday\JsonMapper\Value\ClosureTypeHandler; use stdClass; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -294,7 +300,7 @@ and optional the name of a collection class to the method. require __DIR__ . '/vendor/autoload.php'; use ArrayObject; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -333,7 +339,7 @@ A complete set-up may look like this: require __DIR__ . '/vendor/autoload.php'; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -372,7 +378,7 @@ Use `JsonMapper::addCustomClassMapEntry()` when the target class depends on runt ```php require __DIR__ . '/vendor/autoload.php'; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -413,7 +419,7 @@ The mapper operates in a lenient mode by default. Switch to strict mapping when require __DIR__ . '/vendor/autoload.php'; use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -453,7 +459,7 @@ Type resolution is the most expensive part of a mapping run. Provide a PSR-6 cac ```php require __DIR__ . '/vendor/autoload.php'; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; diff --git a/docs/API.md b/docs/API.md index 3556f5e..6fd906f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -10,7 +10,7 @@ The `JsonMapper` class is the main entry point for mapping arbitrary JSON struct map($json, Article::class, configuration: $configuration); +$article = $mapper->map($json, Article::class); assert($article instanceof Article); assert($article->status === Status::Published); ``` -The mapper validates enum values. When strict mode is enabled (`JsonMapperConfiguration::strict()`), an invalid enum value results in a `TypeMismatchException`. + +The mapper validates enum values. In strict mode (`JsonMapperConfiguration::strict()`), an invalid enum value results in a `TypeMismatchException` instead of populating the property. + +Test coverage: `tests/JsonMapperTest.php::mapBackedEnumFromString` and `tests/JsonMapper/JsonMapperErrorHandlingTest.php::itReportsInvalidEnumValuesInLenientMode`. diff --git a/docs/recipes/nested-collections.md b/docs/recipes/nested-collections.md index 4eb677f..9a36981 100644 --- a/docs/recipes/nested-collections.md +++ b/docs/recipes/nested-collections.md @@ -53,7 +53,7 @@ use App\Dto\Article; use App\Dto\ArticleCollection; use App\Dto\NestedTagCollection; use App\Dto\TagCollection; -use MagicSunday\JsonMapper\JsonMapper; +use MagicSunday\JsonMapper; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -87,3 +87,6 @@ assert($articles[0]->tags instanceof NestedTagCollection); ``` Each custom collection advertises its value type through the `@extends` PHPDoc annotation, allowing the mapper to recurse through nested structures. + +Test coverage: `tests/JsonMapper/DocsNestedCollectionsTest.php`. + diff --git a/docs/recipes/using-attributes.md b/docs/recipes/using-attributes.md index 46e9b79..16611ad 100644 --- a/docs/recipes/using-attributes.md +++ b/docs/recipes/using-attributes.md @@ -15,12 +15,17 @@ use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; final class User { + /** + * @var list + */ #[ReplaceNullWithDefaultValue] public array $roles = []; } ``` -When a payload contains `{ "roles": null }`, the mapper keeps the default empty array. +When a payload contains `{ "roles": null }`, the mapper keeps the default empty array instead of overwriting it with `null`. + +Test coverage: `tests/JsonMapperTest.php::mapNullToDefaultValueUsingAttribute`. ## `ReplaceProperty` Apply this attribute at class level to redirect one or more incoming property names to a different target property. @@ -33,13 +38,16 @@ namespace App\Dto; use MagicSunday\JsonMapper\Attribute\ReplaceProperty; -#[ReplaceProperty('fullName', replaces: ['first_name', 'name'])] +#[ReplaceProperty('fullName', replaces: 'first_name')] +#[ReplaceProperty('fullName', replaces: 'name')] final class Contact { public string $fullName; } ``` -Both `first_name` and `name` keys will populate the `$fullName` property. Order matters: the first matching alias wins. +Both `first_name` and `name` keys populate the `$fullName` property. Declare one attribute per alias to express the precedence order explicitly. + +Test coverage: `tests/Attribute/ReplacePropertyTest.php::replaceProperty`. Attributes can be combined with PHPDoc annotations and work alongside the classic DocBlock metadata. From e804dc6ca8263e4033f4cd2e6f91a3efc553d47d Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 12:24:48 +0100 Subject: [PATCH 43/46] feat(jsonmapper): add factory helper for default services --- CHANGELOG.md | 3 ++ README.md | 16 ++----- docs/API.md | 27 +++++------ src/JsonMapper.php | 33 +++++++++++++ tests/Classes/CamelCasePerson.php | 20 ++++++++ tests/JsonMapper/JsonMapperFactoryTest.php | 54 ++++++++++++++++++++++ 6 files changed, 128 insertions(+), 25 deletions(-) create mode 100644 tests/Classes/CamelCasePerson.php create mode 100644 tests/JsonMapper/JsonMapperFactoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2e6ad..6434384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +### Added +- Introduced `JsonMapper::createWithDefaults()` to bootstrap the mapper with Symfony reflection, PhpDoc extractors, and a default property accessor. + ### Changed - Marked `MagicSunday\\JsonMapper\\JsonMapper` as `final` and promoted constructor dependencies to `readonly` properties for consistent visibility. - Declared `MagicSunday\\JsonMapper\\Converter\\CamelCasePropertyNameConverter` as `final` and immutable. diff --git a/README.md b/README.md index d25ab76..aaaf95a 100644 --- a/README.md +++ b/README.md @@ -65,23 +65,13 @@ require __DIR__ . '/vendor/autoload.php'; use App\Dto\Article; use App\Dto\ArticleCollection; use MagicSunday\JsonMapper; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; -use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; // Decode a single article and a list of articles, raising on malformed JSON. $single = json_decode('{"title":"Hello world","comments":[{"message":"First!"}]}', associative: false, flags: JSON_THROW_ON_ERROR); $list = json_decode('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]', associative: false, flags: JSON_THROW_ON_ERROR); -// Configure JsonMapper with reflection and PhpDoc support. -$propertyInfo = new PropertyInfoExtractor( - listExtractors: [new ReflectionExtractor()], - typeExtractors: [new PhpDocExtractor()], -); -$propertyAccessor = PropertyAccess::createPropertyAccessor(); - -$mapper = new JsonMapper($propertyInfo, $propertyAccessor); +// Bootstrap JsonMapper with reflection and PhpDoc extractors. +$mapper = JsonMapper::createWithDefaults(); // Map a single DTO and an entire collection in one go. $article = $mapper->map($single, Article::class); @@ -93,6 +83,8 @@ var_dump($article, $articles); The first call produces an `Article` instance with a populated `CommentCollection`; the second call returns an `ArticleCollection` containing `Article` objects. +`JsonMapper::createWithDefaults()` wires the default Symfony `PropertyInfoExtractor` (reflection + PhpDoc) and a `PropertyAccessor`. When you need custom extractors, caching, or a specialised accessor you can still instantiate `JsonMapper` manually with your preferred services. + Test coverage: `tests/JsonMapper/DocsQuickStartTest.php`. ### PHP classes diff --git a/docs/API.md b/docs/API.md index 6fd906f..6b8d678 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,6 +5,18 @@ This document summarises the public surface of the JsonMapper package. All class ## JsonMapper (final) The `JsonMapper` class is the main entry point for mapping arbitrary JSON structures to PHP objects. The class is `final`; prefer composition over inheritance. +### Factory helper +```php +valueConverter->addStrategy(new PassthroughValueConversionStrategy()); } + /** + * Creates a mapper with sensible default Symfony services. + * + * @param PropertyNameConverterInterface|null $nameConverter Optional converter to normalise incoming property names. + * @param array $classMap Optional class map forwarded to the mapper constructor. + * @param CacheItemPoolInterface|null $typeCache Optional cache for resolved type information. + * @param JsonMapperConfiguration|null $config Default mapper configuration cloned for new mapping contexts. + */ + public static function createWithDefaults( + ?PropertyNameConverterInterface $nameConverter = null, + array $classMap = [], + ?CacheItemPoolInterface $typeCache = null, + ?JsonMapperConfiguration $config = null, + ): self { + $extractor = new PropertyInfoExtractor( + [new ReflectionExtractor()], + [new PhpDocExtractor()], + ); + + return new self( + $extractor, + PropertyAccess::createPropertyAccessor(), + $nameConverter, + $classMap, + $typeCache, + $config ?? new JsonMapperConfiguration(), + ); + } + /** * Registers a custom type handler. * diff --git a/tests/Classes/CamelCasePerson.php b/tests/Classes/CamelCasePerson.php new file mode 100644 index 0000000..096de94 --- /dev/null +++ b/tests/Classes/CamelCasePerson.php @@ -0,0 +1,20 @@ + 42, + 'name' => 'Example', + ]; + + $result = $mapper->map($payload, Simple::class); + + self::assertInstanceOf(Simple::class, $result); + self::assertSame(42, $result->id); + self::assertSame('Example', $result->name); + } + + public function testCreateWithDefaultsUsesProvidedNameConverter(): void + { + $mapper = JsonMapper::createWithDefaults(new CamelCasePropertyNameConverter()); + + $payload = (object) [ + 'first_name' => 'Ada', + ]; + + $result = $mapper->map($payload, CamelCasePerson::class); + + self::assertInstanceOf(CamelCasePerson::class, $result); + self::assertSame('Ada', $result->firstName); + } +} From 5caf028d9e91aabf7a39282d8bdb25b61ceac3e6 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 12:30:17 +0100 Subject: [PATCH 44/46] refactor(mapper): extract mapping helpers --- src/JsonMapper.php | 247 +++++++++++++++++++++++++++++++-------------- 1 file changed, 174 insertions(+), 73 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 6110d2e..f5cbca2 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -237,7 +237,6 @@ public function map( $configuration = JsonMapperConfiguration::fromContext($context); } - // Resolve the target class and optional collection from the configured resolvers. $resolvedClassName = $className === null ? null : $this->classResolver->resolve($className, $json, $context); @@ -248,38 +247,116 @@ public function map( $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); - /** @var Type|null $collectionValueType */ - $collectionValueType = null; + $collectionValueType = $this->extractCollectionType($resolvedClassName, $resolvedCollectionClassName); - // Determine the element type when the mapping targets a collection. - if ($resolvedCollectionClassName !== null) { - if ($resolvedClassName !== null) { - $collectionValueType = new ObjectType($resolvedClassName); - } else { - $docBlockCollectionType = $this->collectionDocBlockTypeResolver->resolve($resolvedCollectionClassName); - - if (!$docBlockCollectionType instanceof CollectionType) { - throw new InvalidArgumentException(sprintf( - 'Unable to resolve the element type for collection [%s]. Define an "@extends" annotation such as "@extends %s".', - $resolvedCollectionClassName, - $resolvedCollectionClassName, - )); - } + $collectionResult = $this->mapCollection( + $json, + $resolvedClassName, + $resolvedCollectionClassName, + $collectionValueType, + $context, + ); - $collectionValueType = $docBlockCollectionType->getCollectionValueType(); + if ($collectionResult !== null) { + return $collectionResult; + } - if ($collectionValueType instanceof TemplateType) { - throw new InvalidArgumentException(sprintf( - 'Unable to resolve the element type for collection [%s]. Please provide a concrete class in the "@extends" annotation.', - $resolvedCollectionClassName, - )); - } - } + if ($resolvedClassName === null) { + return $json; } + if (!is_array($json) && !is_object($json)) { + return $this->makeInstance($resolvedClassName); + } + + return $this->mapSingleObject($json, $resolvedClassName, $context, $configuration); + } + + /** + * Maps the JSON structure and returns a detailed mapping report. + * + * @param mixed $json Source data to map into PHP objects. + * @param class-string|null $className Fully qualified class name that should be instantiated for mapped objects. + * @param class-string|null $collectionClassName Collection class that should wrap the mapped objects when required. + * @param JsonMapperConfiguration|null $configuration Optional configuration that overrides the default mapper settings. + * + * @return MappingResult Mapping result containing the mapped value and a detailed report. + */ + public function mapWithReport( + mixed $json, + ?string $className = null, + ?string $collectionClassName = null, + ?JsonMapperConfiguration $configuration = null, + ): MappingResult { + $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); + $context = new MappingContext($json, $configuration->toOptions()); + $value = $this->map($json, $className, $collectionClassName, $context, $configuration); + + return new MappingResult($value, new MappingReport($context->getErrorRecords())); + } + + /** + * Extracts the collection element type based on the resolved class information. + * + * @param class-string|null $resolvedClassName Fully qualified class name resolved for the mapped elements. + * @param class-string|null $resolvedCollectionClassName Fully qualified collection class wrapping the mapped elements. + * + * @return Type|null Element type derived from the collection definition when available. + */ + private function extractCollectionType( + ?string $resolvedClassName, + ?string $resolvedCollectionClassName, + ): ?Type { + if ($resolvedCollectionClassName === null) { + return null; + } + + if ($resolvedClassName !== null) { + return new ObjectType($resolvedClassName); + } + + $docBlockCollectionType = $this->collectionDocBlockTypeResolver->resolve($resolvedCollectionClassName); + + if (!$docBlockCollectionType instanceof CollectionType) { + throw new InvalidArgumentException(sprintf( + 'Unable to resolve the element type for collection [%s]. Define an "@extends" annotation such as "@extends %s".', + $resolvedCollectionClassName, + $resolvedCollectionClassName, + )); + } + + $collectionValueType = $docBlockCollectionType->getCollectionValueType(); + + if ($collectionValueType instanceof TemplateType) { + throw new InvalidArgumentException(sprintf( + 'Unable to resolve the element type for collection [%s]. Please provide a concrete class in the "@extends" annotation.', + $resolvedCollectionClassName, + )); + } + + return $collectionValueType; + } + + /** + * Maps iterable payloads into the configured collection structure when applicable. + * + * @param mixed $json Source payload that may represent a collection. + * @param class-string|null $resolvedClassName Fully qualified class name resolved for mapped elements. + * @param class-string|null $resolvedCollectionClassName Fully qualified collection class wrapping the mapped elements. + * @param Type|null $collectionValueType Element type derived from the collection definition. + * @param MappingContext $context Mapping context forwarded to nested mappings. + * + * @return mixed|null Returns the mapped collection when handled, null otherwise. + */ + private function mapCollection( + mixed $json, + ?string $resolvedClassName, + ?string $resolvedCollectionClassName, + ?Type $collectionValueType, + MappingContext $context, + ): mixed { $isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType !== null; - // Map into a standalone collection when the element class is derived from the collection definition. if ($isGenericCollectionMapping) { if ($resolvedCollectionClassName === null) { throw new InvalidArgumentException('A collection class name must be provided when mapping without an element class.'); @@ -291,31 +368,46 @@ public function map( } if ($resolvedClassName === null) { - return $json; + return null; } - if (!is_array($json) && !is_object($json)) { - return $this->makeInstance($resolvedClassName); + if (!$this->isIterableWithArraysOrObjects($json)) { + return null; } - // Map array or object sources into the configured collection type when requested. - if ( - ($resolvedCollectionClassName !== null) - && $this->isIterableWithArraysOrObjects($json) - ) { - $collection = $this->collectionFactory->mapIterable($json, $collectionValueType ?? new ObjectType($resolvedClassName), $context); + /** @var array|object $json */ + + $valueType = $collectionValueType ?? new ObjectType($resolvedClassName); + + if ($resolvedCollectionClassName !== null) { + $collection = $this->collectionFactory->mapIterable($json, $valueType, $context); return $this->makeInstance($resolvedCollectionClassName, $collection); } - // Handle sequential arrays by mapping them into a native collection of resolved objects. - if ( - $this->isIterableWithArraysOrObjects($json) - && $this->isNumericIndexArray($json) - ) { - return $this->collectionFactory->mapIterable($json, new ObjectType($resolvedClassName), $context); + if ($this->isNumericIndexArray($json)) { + return $this->collectionFactory->mapIterable($json, $valueType, $context); } + return null; + } + + /** + * Maps a single object or associative array onto the resolved class instance. + * + * @param array|object $json Source payload representing the object to map. + * @param class-string $resolvedClassName Fully qualified class name that receives the mapped values. + * @param MappingContext $context Mapping context forwarded to nested mappings. + * @param JsonMapperConfiguration $configuration Effective configuration guiding the mapping process. + * + * @return object Instantiated and populated object that represents the mapped payload. + */ + private function mapSingleObject( + array|object $json, + string $resolvedClassName, + MappingContext $context, + JsonMapperConfiguration $configuration, + ): object { $entity = $this->makeInstance($resolvedClassName); $source = $this->toIterableArray($json); @@ -323,7 +415,6 @@ public function map( $replacePropertyMap = $this->buildReplacePropertyMap($resolvedClassName); $mappedProperties = []; - // Iterate over the source data and map each property onto the target entity. foreach ($source as $propertyName => $propertyValue) { $normalizedProperty = $this->normalizePropertyName($propertyName, $replacePropertyMap); $pathSegment = is_string($normalizedProperty) ? $normalizedProperty : (string) $propertyName; @@ -341,23 +432,21 @@ public function map( return; } - if (!in_array($normalizedProperty, $properties, true)) { - if ($configuration->shouldIgnoreUnknownProperties()) { - return; - } - - $this->handleMappingException( - new UnknownPropertyException($propertyContext->getPath(), $normalizedProperty, $resolvedClassName), - $propertyContext, - $configuration, - ); + $validatedProperty = $this->validateAndNormalize( + $normalizedProperty, + $properties, + $configuration, + $propertyContext, + $resolvedClassName, + ); + if ($validatedProperty === null) { return; } - $mappedProperties[] = $normalizedProperty; + $mappedProperties[] = $validatedProperty; - $type = $this->typeResolver->resolve($resolvedClassName, $normalizedProperty); + $type = $this->typeResolver->resolve($resolvedClassName, $validatedProperty); try { $value = $this->convertValue($propertyValue, $type, $propertyContext); @@ -369,13 +458,13 @@ public function map( if ( ($value === null) - && $this->isReplaceNullWithDefaultValueAnnotation($resolvedClassName, $normalizedProperty) + && $this->isReplaceNullWithDefaultValueAnnotation($resolvedClassName, $validatedProperty) ) { - $value = $this->getDefaultValue($resolvedClassName, $normalizedProperty); + $value = $this->getDefaultValue($resolvedClassName, $validatedProperty); } try { - $this->setProperty($entity, $normalizedProperty, $value, $propertyContext); + $this->setProperty($entity, $validatedProperty, $value, $propertyContext); } catch (ReadonlyPropertyException $exception) { $this->handleMappingException($exception, $propertyContext, $configuration); } @@ -402,26 +491,38 @@ public function map( } /** - * Maps the JSON structure and returns a detailed mapping report. + * Validates the normalized property name and reports unknown properties when required. * - * @param mixed $json Source data to map into PHP objects. - * @param class-string|null $className Fully qualified class name that should be instantiated for mapped objects. - * @param class-string|null $collectionClassName Collection class that should wrap the mapped objects when required. - * @param JsonMapperConfiguration|null $configuration Optional configuration that overrides the default mapper settings. + * @param string $normalizedProperty Normalized property name derived from the payload. + * @param array $properties Declared properties available on the target class. + * @param JsonMapperConfiguration $configuration Effective configuration guiding the mapping process. + * @param MappingContext $context Mapping context scoped to the current property. + * @param class-string $resolvedClassName Fully qualified class name receiving the mapped values. * - * @return MappingResult Mapping result containing the mapped value and a detailed report. + * @return string|null Returns the validated property name or null when the property should be skipped. */ - public function mapWithReport( - mixed $json, - ?string $className = null, - ?string $collectionClassName = null, - ?JsonMapperConfiguration $configuration = null, - ): MappingResult { - $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); - $context = new MappingContext($json, $configuration->toOptions()); - $value = $this->map($json, $className, $collectionClassName, $context, $configuration); + private function validateAndNormalize( + string $normalizedProperty, + array $properties, + JsonMapperConfiguration $configuration, + MappingContext $context, + string $resolvedClassName, + ): ?string { + if (!in_array($normalizedProperty, $properties, true)) { + if ($configuration->shouldIgnoreUnknownProperties()) { + return null; + } - return new MappingResult($value, new MappingReport($context->getErrorRecords())); + $this->handleMappingException( + new UnknownPropertyException($context->getPath(), $normalizedProperty, $resolvedClassName), + $context, + $configuration, + ); + + return null; + } + + return $normalizedProperty; } /** From 118cc40bfffb779f05a0774a088ccd4ad39333f0 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 12:32:25 +0100 Subject: [PATCH 45/46] Update --- src/JsonMapper.php | 23 +++++++++++----------- tests/JsonMapper/JsonMapperFactoryTest.php | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 49a2c28..adb21a2 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -373,11 +373,11 @@ private function extractCollectionType( /** * Maps iterable payloads into the configured collection structure when applicable. * - * @param mixed $json Source payload that may represent a collection. - * @param class-string|null $resolvedClassName Fully qualified class name resolved for mapped elements. - * @param class-string|null $resolvedCollectionClassName Fully qualified collection class wrapping the mapped elements. - * @param Type|null $collectionValueType Element type derived from the collection definition. - * @param MappingContext $context Mapping context forwarded to nested mappings. + * @param mixed $json Source payload that may represent a collection. + * @param class-string|null $resolvedClassName Fully qualified class name resolved for mapped elements. + * @param class-string|null $resolvedCollectionClassName Fully qualified collection class wrapping the mapped elements. + * @param Type|null $collectionValueType Element type derived from the collection definition. + * @param MappingContext $context Mapping context forwarded to nested mappings. * * @return mixed|null Returns the mapped collection when handled, null otherwise. */ @@ -388,7 +388,7 @@ private function mapCollection( ?Type $collectionValueType, MappingContext $context, ): mixed { - $isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType !== null; + $isGenericCollectionMapping = $resolvedClassName === null && $collectionValueType instanceof Type; if ($isGenericCollectionMapping) { if ($resolvedCollectionClassName === null) { @@ -409,7 +409,6 @@ private function mapCollection( } /** @var array|object $json */ - $valueType = $collectionValueType ?? new ObjectType($resolvedClassName); if ($resolvedCollectionClassName !== null) { @@ -428,7 +427,7 @@ private function mapCollection( /** * Maps a single object or associative array onto the resolved class instance. * - * @param array|object $json Source payload representing the object to map. + * @param array|object $json Source payload representing the object to map. * @param class-string $resolvedClassName Fully qualified class name that receives the mapped values. * @param MappingContext $context Mapping context forwarded to nested mappings. * @param JsonMapperConfiguration $configuration Effective configuration guiding the mapping process. @@ -527,10 +526,10 @@ private function mapSingleObject( * Validates the normalized property name and reports unknown properties when required. * * @param string $normalizedProperty Normalized property name derived from the payload. - * @param array $properties Declared properties available on the target class. - * @param JsonMapperConfiguration $configuration Effective configuration guiding the mapping process. - * @param MappingContext $context Mapping context scoped to the current property. - * @param class-string $resolvedClassName Fully qualified class name receiving the mapped values. + * @param array $properties Declared properties available on the target class. + * @param JsonMapperConfiguration $configuration Effective configuration guiding the mapping process. + * @param MappingContext $context Mapping context scoped to the current property. + * @param class-string $resolvedClassName Fully qualified class name receiving the mapped values. * * @return string|null Returns the validated property name or null when the property should be skipped. */ diff --git a/tests/JsonMapper/JsonMapperFactoryTest.php b/tests/JsonMapper/JsonMapperFactoryTest.php index ec4f7d0..5724a77 100644 --- a/tests/JsonMapper/JsonMapperFactoryTest.php +++ b/tests/JsonMapper/JsonMapperFactoryTest.php @@ -27,7 +27,7 @@ public function testCreateWithDefaultsReturnsConfiguredMapper(): void $mapper = JsonMapper::createWithDefaults(); $payload = (object) [ - 'id' => 42, + 'id' => 42, 'name' => 'Example', ]; From 1e2997ecef90164740c2875c90fbf466afb5923c Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Fri, 14 Nov 2025 12:50:18 +0100 Subject: [PATCH 46/46] Minor update --- CHANGELOG.md | 12 ----- src/JsonMapper.php | 131 +++++++++++++++++++++++++++++++++------------ 2 files changed, 98 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6434384..e69de29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +0,0 @@ -## Unreleased - -### Added -- Introduced `JsonMapper::createWithDefaults()` to bootstrap the mapper with Symfony reflection, PhpDoc extractors, and a default property accessor. - -### Changed -- Marked `MagicSunday\\JsonMapper\\JsonMapper` as `final` and promoted constructor dependencies to `readonly` properties for consistent visibility. -- Declared `MagicSunday\\JsonMapper\\Converter\\CamelCasePropertyNameConverter` as `final` and immutable. - -### Documentation -- Added a quick start walkthrough and guidance on type converters, error strategies, and performance tuning to the README. -- Published an API reference (`docs/API.md`) and new recipe guides for enums, attributes, nested collections, and custom name converters. diff --git a/src/JsonMapper.php b/src/JsonMapper.php index adb21a2..481b014 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -157,7 +157,13 @@ function (string $className, ?array $arguments): object { function (mixed $value, string $resolvedClass, MappingContext $context): mixed { $configuration = JsonMapperConfiguration::fromContext($context); - return $this->map($value, $resolvedClass, null, $context, $configuration); + return $this->map( + $value, + $resolvedClass, + null, + $context, + $configuration + ); }, ), ); @@ -280,7 +286,10 @@ public function map( $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); - $collectionValueType = $this->extractCollectionType($resolvedClassName, $resolvedCollectionClassName); + $collectionValueType = $this->extractCollectionType( + $resolvedClassName, + $resolvedCollectionClassName + ); $collectionResult = $this->mapCollection( $json, @@ -323,7 +332,14 @@ public function mapWithReport( ): MappingResult { $configuration = ($configuration ?? $this->createDefaultConfiguration())->withErrorCollection(true); $context = new MappingContext($json, $configuration->toOptions()); - $value = $this->map($json, $className, $collectionClassName, $context, $configuration); + + $value = $this->map( + $json, + $className, + $collectionClassName, + $context, + $configuration + ); return new MappingResult($value, new MappingReport($context->getErrorRecords())); } @@ -351,20 +367,24 @@ private function extractCollectionType( $docBlockCollectionType = $this->collectionDocBlockTypeResolver->resolve($resolvedCollectionClassName); if (!$docBlockCollectionType instanceof CollectionType) { - throw new InvalidArgumentException(sprintf( - 'Unable to resolve the element type for collection [%s]. Define an "@extends" annotation such as "@extends %s".', - $resolvedCollectionClassName, - $resolvedCollectionClassName, - )); + throw new InvalidArgumentException( + sprintf( + 'Unable to resolve the element type for collection [%s]. Define an "@extends" annotation such as "@extends %s".', + $resolvedCollectionClassName, + $resolvedCollectionClassName, + ) + ); } $collectionValueType = $docBlockCollectionType->getCollectionValueType(); if ($collectionValueType instanceof TemplateType) { - throw new InvalidArgumentException(sprintf( - 'Unable to resolve the element type for collection [%s]. Please provide a concrete class in the "@extends" annotation.', - $resolvedCollectionClassName, - )); + throw new InvalidArgumentException( + sprintf( + 'Unable to resolve the element type for collection [%s]. Please provide a concrete class in the "@extends" annotation.', + $resolvedCollectionClassName, + ) + ); } return $collectionValueType; @@ -392,7 +412,9 @@ private function mapCollection( if ($isGenericCollectionMapping) { if ($resolvedCollectionClassName === null) { - throw new InvalidArgumentException('A collection class name must be provided when mapping without an element class.'); + throw new InvalidArgumentException( + 'A collection class name must be provided when mapping without an element class.' + ); } $collection = $this->collectionFactory->mapIterable($json, $collectionValueType, $context); @@ -576,8 +598,11 @@ private function createDefaultConfiguration(): JsonMapperConfiguration * * @return list List of property names that are still required after mapping. */ - private function determineMissingProperties(string $className, array $declaredProperties, array $mappedProperties): array - { + private function determineMissingProperties( + string $className, + array $declaredProperties, + array $mappedProperties, + ): array { $used = array_values(array_unique($mappedProperties)); return array_values(array_filter( @@ -632,8 +657,11 @@ private function isRequiredProperty(string $className, string $propertyName): bo * @param MappingContext $context Context collecting the error information. * @param JsonMapperConfiguration $configuration Configuration that controls strict-mode behaviour. */ - private function handleMappingException(MappingException $exception, MappingContext $context, JsonMapperConfiguration $configuration): void - { + private function handleMappingException( + MappingException $exception, + MappingContext $context, + JsonMapperConfiguration $configuration, + ): void { $context->recordException($exception); // Strict mode propagates the failure immediately to abort mapping on the first error. @@ -645,8 +673,11 @@ private function handleMappingException(MappingException $exception, MappingCont /** * Converts the provided JSON value using the registered strategies. */ - private function convertValue(mixed $json, Type $type, MappingContext $context): mixed - { + private function convertValue( + mixed $json, + Type $type, + MappingContext $context, + ): mixed { if ( is_string($json) && ($json === '' || trim($json) === '') @@ -679,8 +710,11 @@ private function convertValue(mixed $json, Type $type, MappingContext $context): * * @return mixed Value converted to a type accepted by the union. */ - private function convertUnionValue(mixed $json, UnionType $type, MappingContext $context): mixed - { + private function convertUnionValue( + mixed $json, + UnionType $type, + MappingContext $context, + ): mixed { if ($json === null && $this->unionAllowsNull($type)) { return null; } @@ -810,7 +844,7 @@ private function unionAllowsNull(UnionType $type): bool */ private function isNullType(Type $type): bool { - return $type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::NULL; + return ($type instanceof BuiltinType) && ($type->getTypeIdentifier() === TypeIdentifier::NULL); } /** @@ -860,9 +894,10 @@ private function buildReplacePropertyMap(string $className): array return []; } - $map = []; + $map = []; + $attributes = $reflectionClass->getAttributes(ReplaceProperty::class, ReflectionAttribute::IS_INSTANCEOF); - foreach ($reflectionClass->getAttributes(ReplaceProperty::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + foreach ($attributes as $attribute) { /** @var ReplaceProperty $instance */ $instance = $attribute->newInstance(); $map[$instance->replaces] = $instance->value; @@ -896,11 +931,17 @@ private function normalizePropertyName(string|int $propertyName, array $replaceP { $normalized = $propertyName; - if (is_string($normalized) && array_key_exists($normalized, $replacePropertyMap)) { + if ( + is_string($normalized) + && array_key_exists($normalized, $replacePropertyMap) + ) { $normalized = $replacePropertyMap[$normalized]; } - if (is_string($normalized) && ($this->nameConverter instanceof PropertyNameConverterInterface)) { + if ( + is_string($normalized) + && ($this->nameConverter instanceof PropertyNameConverterInterface) + ) { return $this->nameConverter->convert($normalized); } @@ -1002,7 +1043,10 @@ private function isNumericIndexArray(array|object $json): bool */ private function isIterableWithArraysOrObjects(mixed $json): bool { - if (!is_array($json) && !is_object($json)) { + if ( + !is_array($json) + && !is_object($json) + ) { return false; } @@ -1028,8 +1072,16 @@ private function isIterableWithArraysOrObjects(mixed $json): bool */ private function assertClassesExists(?string $className, ?string $collectionClassName = null): void { - if ($className !== null && !class_exists($className)) { - throw new InvalidArgumentException(sprintf('Class [%s] does not exist', $className)); + if ( + ($className !== null) + && !class_exists($className) + ) { + throw new InvalidArgumentException( + sprintf( + 'Class [%s] does not exist', + $className + ) + ); } if ($collectionClassName === null) { @@ -1040,18 +1092,31 @@ private function assertClassesExists(?string $className, ?string $collectionClas return; } - throw new InvalidArgumentException(sprintf('Class [%s] does not exist', $collectionClassName)); + throw new InvalidArgumentException( + sprintf( + 'Class [%s] does not exist', + $collectionClassName + ) + ); } /** * Sets a property value. */ - private function setProperty(object $entity, string $name, mixed $value, MappingContext $context): void - { + private function setProperty( + object $entity, + string $name, + mixed $value, + MappingContext $context, + ): void { $reflectionProperty = $this->getReflectionProperty($entity::class, $name); if ($reflectionProperty instanceof ReflectionProperty && $reflectionProperty->isReadOnly()) { - throw new ReadonlyPropertyException($context->getPath(), $name, $entity::class); + throw new ReadonlyPropertyException( + $context->getPath(), + $name, + $entity::class + ); } if (is_array($value)) {