diff --git a/README.md b/README.md index f39b5e5..aaaf95a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,72 @@ 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 +require __DIR__ . '/vendor/autoload.php'; + +use App\Dto\Article; +use App\Dto\ArticleCollection; +use MagicSunday\JsonMapper; + +// 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); + +// 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); +$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. + +`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 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. @@ -32,19 +98,19 @@ values. For example: ```php -@var SomeCollection -@var SomeCollection -@var Collection\SomeCollection +/** @var SomeCollection $dates */ +/** @var SomeCollection $labels */ +/** @var Collection\\SomeCollection $entities */ ``` -#### 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 @@ -52,31 +118,36 @@ 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\Annotation\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. -##### @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") - */ -class FooClass +namespace App\Dto; + +use MagicSunday\JsonMapper\Attribute\ReplaceProperty; + +#[ReplaceProperty('type', replaces: 'crypticTypeNameProperty')] +final class FooClass { - /** - * @var string - */ - public $type; + public string $type; } ``` @@ -94,108 +165,321 @@ 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; +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, ); ``` 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; +require __DIR__ . '/vendor/autoload.php'; + +use DateTimeImmutable; +use MagicSunday\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(); -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 = new JsonMapper($propertyInfo, $propertyAccessor); + +// Register a handler that hydrates Bar value objects from nested stdClass payloads. +$mapper->addTypeHandler( + new ClosureTypeHandler( + Bar::class, + static function (stdClass $value): Bar { + // Convert the decoded JSON object into a strongly typed Bar instance. + return new Bar($value->name); + }, + ), +); + +// Register a handler for DateTimeImmutable conversion using ISO-8601 timestamps. +$mapper->addTypeHandler( + 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; +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; +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 +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 +require __DIR__ . '/vendor/autoload.php'; + +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; + +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; +}); +``` + +### Error handling strategies +The mapper operates in a lenient mode by default. Switch to strict mapping when every property must be validated: + +```php +require __DIR__ . '/vendor/autoload.php'; + +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; +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; + +final class Article +{ + public string $title; +} + +// 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. + +### 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 +require __DIR__ . '/vendor/autoload.php'; + +use MagicSunday\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); +``` + +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/composer.json b/composer.json index 3fdf156..5d02e77 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", @@ -77,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/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6b8d678 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,149 @@ +# 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. + +### Factory helper +```php + `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: + +```php +getClassName() === FakeUuid::class; + } + + public function convert(Type $type, mixed $value, MappingContext $context): FakeUuid + { + // Build the value object from the incoming scalar payload. + return FakeUuid::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..5dcb92f --- /dev/null +++ b/docs/recipes/custom-name-converter.md @@ -0,0 +1,49 @@ +# 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 +map($json, Article::class); + +assert($article instanceof Article); +assert($article->status === Status::Published); +``` + +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 new file mode 100644 index 0000000..9a36981 --- /dev/null +++ b/docs/recipes/nested-collections.md @@ -0,0 +1,92 @@ +# Mapping nested collections + +Collections of collections require explicit metadata so JsonMapper can determine the element types at every level. + +```php + + */ +final class TagCollection extends ArrayObject +{ +} + +/** + * @extends ArrayObject + */ +final class NestedTagCollection extends ArrayObject +{ +} + +final class Article +{ + /** @var NestedTagCollection */ + public NestedTagCollection $tags; +} + +/** + * @extends ArrayObject + */ +final class ArticleCollection extends ArrayObject +{ +} +``` + +```php +map($json, Article::class, ArticleCollection::class); + +assert($articles instanceof ArticleCollection); +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. + +Test coverage: `tests/JsonMapper/DocsNestedCollectionsTest.php`. + diff --git a/docs/recipes/using-attributes.md b/docs/recipes/using-attributes.md new file mode 100644 index 0000000..16611ad --- /dev/null +++ b/docs/recipes/using-attributes.md @@ -0,0 +1,53 @@ +# 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 + + */ + #[ReplaceNullWithDefaultValue] + public array $roles = []; +} +``` + +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. + +```php +\|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 600ee95..481b014 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -12,32 +12,80 @@ namespace MagicSunday; 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\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\JsonMapperConfiguration; +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; +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; +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\TypeHandlerInterface; +use MagicSunday\JsonMapper\Value\ValueConverter; +use Psr\Cache\CacheItemPoolInterface; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionMethod; +use ReflectionNamedType; use ReflectionProperty; +use ReflectionUnionType; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; 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\TemplateType; use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\TypeInfo\TypeIdentifier; +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_debug_type; +use function get_object_vars; +use function implode; 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 trim; +use function ucfirst; /** * JsonMapper. @@ -45,83 +93,140 @@ * @author Rico Sonntag * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ - * - * @template TEntity - * @template TEntityCollection */ -class JsonMapper +final readonly 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[] + * @var CollectionFactoryInterface */ - private array $classMap; + private CollectionFactoryInterface $collectionFactory; + + private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; + + private CustomTypeRegistry $customTypeRegistry; /** - * The default value type instance. - * - * @var BuiltinType + * 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. */ - private BuiltinType $defaultType; + public function __construct( + private PropertyInfoExtractorInterface $extractor, + private PropertyAccessorInterface $accessor, + private ?PropertyNameConverterInterface $nameConverter = null, + array $classMap = [], + ?CacheItemPoolInterface $typeCache = null, + private JsonMapperConfiguration $config = new JsonMapperConfiguration(), + ) { + $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 { + if ($arguments === null) { + return $this->makeInstance($className); + } + + 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 DateTimeValueConversionStrategy()); + $this->valueConverter->addStrategy(new EnumValueConversionStrategy()); + $this->valueConverter->addStrategy( + new ObjectValueConversionStrategy( + $this->classResolver, + function (mixed $value, string $resolvedClass, MappingContext $context): mixed { + $configuration = JsonMapperConfiguration::fromContext($context); + + return $this->map( + $value, + $resolvedClass, + null, + $context, + $configuration + ); + }, + ), + ); + $this->valueConverter->addStrategy(new BuiltinValueConversionStrategy()); + $this->valueConverter->addStrategy(new PassthroughValueConversionStrategy()); + } /** - * The custom types. + * Creates a mapper with sensible default Symfony services. * - * @var Closure[] + * @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. */ - private array $types = []; + 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(), + ); + } /** - * JsonMapper constructor. + * Registers a custom type handler. + * + * @param TypeHandlerInterface $handler Type handler implementation to register with the mapper. * - * @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 + * @return JsonMapper Returns the mapper instance for fluent configuration. */ - public function __construct( - PropertyInfoExtractorInterface $extractor, - PropertyAccessorInterface $accessor, - ?PropertyNameConverterInterface $nameConverter = null, - array $classMap = [], - ) { - $this->extractor = $extractor; - $this->accessor = $accessor; - $this->nameConverter = $nameConverter; - $this->defaultType = new BuiltinType(TypeIdentifier::STRING); - $this->classMap = $classMap; + public function addTypeHandler(TypeHandlerInterface $handler): JsonMapper + { + $this->customTypeRegistry->registerHandler($handler); + + return $this; } /** - * Add a custom type. + * 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 string $type The type name - * @param Closure $closure The closure to execute for the defined type + * @deprecated Use addTypeHandler() with a TypeHandlerInterface implementation instead. * - * @return JsonMapper + * @return JsonMapper Returns the mapper instance for fluent configuration. */ public function addType(string $type, Closure $closure): JsonMapper { - $this->types[$type] = $closure; + $this->customTypeRegistry->registerHandler(new ClosureTypeHandler($type, $closure)); return $this; } @@ -129,16 +234,17 @@ public function addType(string $type, Closure $closure): JsonMapper /** * Add a custom class map entry. * - * @template T + * @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. * - * @param class-string $className The name of the base class - * @param Closure $closure The closure to execute if the base class was found + * @phpstan-param class-string $className + * @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure * - * @return JsonMapper + * @return JsonMapper Returns the mapper instance for fluent configuration. */ public function addCustomClassMapEntry(string $className, Closure $closure): JsonMapper { - $this->classMap[$className] = $closure; + $this->classResolver->add($className, $closure); return $this; } @@ -146,275 +252,582 @@ 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 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|TEntityCollection|TEntity|null + * @return mixed The mapped PHP value or collection produced from the given JSON. + */ + public function map( + mixed $json, + ?string $className = null, + ?string $collectionClassName = null, + ?MappingContext $context = null, + ?JsonMapperConfiguration $configuration = null, + ): mixed { + if (!$context instanceof MappingContext) { + $configuration ??= $this->createDefaultConfiguration(); + $context = new MappingContext($json, $configuration->toOptions()); + } elseif ($configuration instanceof JsonMapperConfiguration) { + $context->replaceOptions($configuration->toOptions()); + } else { + $configuration = JsonMapperConfiguration::fromContext($context); + } + + $resolvedClassName = $className === null + ? null + : $this->classResolver->resolve($className, $json, $context); + + $resolvedCollectionClassName = $collectionClassName === null + ? null + : $this->classResolver->resolve($collectionClassName, $json, $context); + + $this->assertClassesExists($resolvedClassName, $resolvedCollectionClassName); + + $collectionValueType = $this->extractCollectionType( + $resolvedClassName, + $resolvedCollectionClassName + ); + + $collectionResult = $this->mapCollection( + $json, + $resolvedClassName, + $resolvedCollectionClassName, + $collectionValueType, + $context, + ); + + if ($collectionResult !== null) { + return $collectionResult; + } + + 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. * - * @phpstan-return ($collectionClassName is class-string - * ? TEntityCollection - * : ($className is class-string ? TEntity : null|mixed)) + * @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. * - * @throws DomainException - * @throws InvalidArgumentException + * @return MappingResult Mapping result containing the mapped value and a detailed report. */ - public function map(mixed $json, ?string $className = null, ?string $collectionClassName = null) - { - // Return plain JSON if no mapping classes are provided - if ($className === null) { - return $json; + 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); } - // Map the original given class names to a custom ones - $className = $this->getMappedClassName($className, $json); + $docBlockCollectionType = $this->collectionDocBlockTypeResolver->resolve($resolvedCollectionClassName); - if ($collectionClassName !== null) { - $collectionClassName = $this->getMappedClassName($collectionClassName, $json); + 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, + ) + ); } - // Assert that the given classes exist - $this->assertClassesExists($className, $collectionClassName); + $collectionValueType = $docBlockCollectionType->getCollectionValueType(); - // 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) - ) - ); - } + 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; + } - // 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) + /** + * 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 instanceof Type; + + 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); } - $properties = $this->getProperties($className); - $entity = $this->makeInstance($className); + if ($resolvedClassName === null) { + return null; + } - // 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; + if (!$this->isIterableWithArraysOrObjects($json)) { + return null; } - // Process all children + /** @var array|object $json */ + $valueType = $collectionValueType ?? new ObjectType($resolvedClassName); - /** @var string|int $propertyName */ - foreach ($json as $propertyName => $propertyValue) { - // Replaces the property name with another one - if ($this->isReplacePropertyAnnotation($className)) { - $annotations = $this->extractClassAnnotations($className); + if ($resolvedCollectionClassName !== null) { + $collection = $this->collectionFactory->mapIterable($json, $valueType, $context); - foreach ($annotations as $annotation) { - if ( - ($annotation instanceof ReplaceProperty) - && ($propertyName === $annotation->replaces) - ) { - /** @var string $propertyName */ - $propertyName = $annotation->value; - } + return $this->makeInstance($resolvedCollectionClassName, $collection); + } + + 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); + + $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; + + $context->withPathSegment($pathSegment, function (MappingContext $propertyContext) use ( + $resolvedClassName, + $normalizedProperty, + $propertyValue, + $entity, + &$mappedProperties, + $properties, + $configuration, + ): void { + if (!is_string($normalizedProperty)) { + return; } - } - if (is_string($propertyName) - && ($this->nameConverter instanceof PropertyNameConverterInterface) - ) { - $propertyName = $this->nameConverter->convert($propertyName); - } + $validatedProperty = $this->validateAndNormalize( + $normalizedProperty, + $properties, + $configuration, + $propertyContext, + $resolvedClassName, + ); - // Ignore all not defined properties - if (!in_array($propertyName, $properties, true)) { - continue; - } + if ($validatedProperty === null) { + return; + } - $type = $this->getType($className, $propertyName); - $value = $this->getValue($propertyValue, $type); + $mappedProperties[] = $validatedProperty; - if ( - ($value === null) - && $this->isReplaceNullWithDefaultValueAnnotation($className, $propertyName) - ) { - // Get the default value of the property - $value = $this->getDefaultValue($className, $propertyName); - } + $type = $this->typeResolver->resolve($resolvedClassName, $validatedProperty); + + try { + $value = $this->convertValue($propertyValue, $type, $propertyContext); + } catch (MappingException $exception) { + $this->handleMappingException($exception, $propertyContext, $configuration); + + return; + } + + if ( + ($value === null) + && $this->isReplaceNullWithDefaultValueAnnotation($resolvedClassName, $validatedProperty) + ) { + $value = $this->getDefaultValue($resolvedClassName, $validatedProperty); + } + + try { + $this->setProperty($entity, $validatedProperty, $value, $propertyContext); + } catch (ReadonlyPropertyException $exception) { + $this->handleMappingException($exception, $propertyContext, $configuration); + } + }); + } - $this->setProperty($entity, $propertyName, $value); + 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; } /** - * Creates an instance of the given class name. If a dependency injection container is provided, - * it returns the instance for this. + * Validates the normalized property name and reports unknown properties when required. * - * @template T of object + * @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 class-string $className The class to instantiate - * @param array|null ...$constructorArguments The arguments of the constructor - * - * @return T + * @return string|null Returns the validated property name or null when the property should be skipped. */ - private function makeInstance(string $className, ?array ...$constructorArguments) - { - /** @var T $instance */ - $instance = new $className(...$constructorArguments); + 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; + } + + $this->handleMappingException( + new UnknownPropertyException($context->getPath(), $normalizedProperty, $resolvedClassName), + $context, + $configuration, + ); - return $instance; + return null; + } + + return $normalizedProperty; } /** - * 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 + * Creates a clone of the default mapper configuration for a fresh mapping context. * - * @return bool + * @return JsonMapperConfiguration Copy of the base configuration that can be mutated safely. */ - private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): bool + private function createDefaultConfiguration(): JsonMapperConfiguration { - return $this->hasPropertyAnnotation( - $className, - $propertyName, - ReplaceNullWithDefaultValue::class - ); + return clone $this->config; } /** - * Returns TRUE if the property contains an "ReplaceProperty" annotation. + * Identifies required properties that were not provided in the source data. * - * @param class-string $className The class name of the initial element + * @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 bool + * @return list List of property names that are still required after mapping. */ - private function isReplacePropertyAnnotation(string $className): bool - { - return $this->hasClassAnnotation( - $className, - ReplaceProperty::class - ); + 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), + )); } /** - * Returns the specified reflection property. + * Determines whether the given property must be present on the input data. * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property + * @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 ReflectionProperty|null + * @return bool True when the property is mandatory and missing values must be reported. */ - private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty + private function isRequiredProperty(string $className, string $propertyName): bool { - try { - return new ReflectionProperty($className, $propertyName); - } catch (ReflectionException) { - return null; + $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; } /** - * Returns the specified reflection class. + * Records a mapping exception and decides whether it should stop the mapping process. * - * @param class-string $className The class name of the initial element - * - * @return ReflectionClass|null + * @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 getReflectionClass(string $className): ?ReflectionClass - { - if (!class_exists($className)) { + 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; + } + } + + /** + * Converts the provided JSON value using the registered strategies. + */ + 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 new ReflectionClass($className); + return $this->valueConverter->convert($json, $type, $context); } /** - * Extracts possible property annotations. + * Converts the value according to the provided union type. * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property + * @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 Annotation[]|object[] + * @return mixed Value converted to a type accepted by the union. */ - private function extractPropertyAnnotations(string $className, string $propertyName): array - { - $reflectionProperty = $this->getReflectionProperty($className, $propertyName); + private function convertUnionValue( + mixed $json, + UnionType $type, + MappingContext $context, + ): mixed { + if ($json === null && $this->unionAllowsNull($type)) { + return null; + } + + $lastException = null; - if ($reflectionProperty instanceof ReflectionProperty) { - return (new AnnotationReader()) - ->getPropertyAnnotations($reflectionProperty); + foreach ($type->getTypes() as $candidate) { + if (($json !== null) && $this->isNullType($candidate)) { + 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 []; + return $json; } /** - * Extracts possible class annotations. - * - * @param class-string $className The class name of the initial element - * - * @return Annotation[]|object[] + * Returns a string representation of the provided type. */ - private function extractClassAnnotations(string $className): array + private function describeType(Type $type): string { - $reflectionClass = $this->getReflectionClass($className); + 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 ($reflectionClass instanceof ReflectionClass) { - return (new AnnotationReader()) - ->getClassAnnotations($reflectionClass); + if ($this->isNullType($type)) { + return 'null'; } - return []; + if ($type instanceof UnionType) { + return $this->describeUnionType($type); + } + + return $type::class; } /** - * Returns TRUE if the property has the given annotation. + * Returns a textual representation of the union type. * - * @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 + * @param UnionType $type Union type converted into a human-readable string. * - * @return bool + * @return string Pipe-separated description of all candidate types. */ - private function hasPropertyAnnotation(string $className, string $propertyName, string $annotationName): bool + private function describeUnionType(UnionType $type): string { - $annotations = $this->extractPropertyAnnotations($className, $propertyName); + $parts = []; - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - return true; - } + foreach ($type->getTypes() as $candidate) { + $parts[] = $this->describeType($candidate); } - return false; + return implode('|', $parts); } /** - * Returns TRUE if the class has the given annotation. + * Checks whether the provided union type accepts null values. * - * @param class-string $className The class name of the initial element - * @param string $annotationName The name of the class annotation + * @param UnionType $type Union type inspected for a nullable member. * - * @return bool + * @return bool True when null is part of the union definition. */ - private function hasClassAnnotation(string $className, string $annotationName): bool + private function unionAllowsNull(UnionType $type): bool { - $annotations = $this->extractClassAnnotations($className); - - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { + foreach ($type->getTypes() as $candidate) { + if ($this->isNullType($candidate)) { return true; } } @@ -423,331 +836,320 @@ private function hasClassAnnotation(string $className, string $annotationName): } /** - * Extracts the default value of a property. + * Checks whether the provided type explicitly represents the null value. * - * @param class-string $className The class name of the initial element - * @param string $propertyName The name of the property + * @param Type $type Type information extracted for a property or union candidate. * - * @return mixed|null + * @return bool True when the type identifies the null built-in. */ - private function getDefaultValue(string $className, string $propertyName): mixed + private function isNullType(Type $type): bool { - $reflectionProperty = $this->getReflectionProperty($className, $propertyName); - - if (!($reflectionProperty instanceof ReflectionProperty)) { - return null; - } - - return $reflectionProperty->getDefaultValue(); + return ($type instanceof BuiltinType) && ($type->getTypeIdentifier() === TypeIdentifier::NULL); } /** - * Returns TRUE if the given JSON contains integer property keys. + * Creates an instance of the given class name. * - * @param array|object $json + * @param string $className Fully qualified class name to instantiate. + * @param mixed ...$constructorArguments Arguments forwarded to the constructor of the class. * - * @return bool + * @return object Newly created instance of the requested class. */ - private function isNumericIndexArray(array|object $json): bool + private function makeInstance(string $className, mixed ...$constructorArguments): object { - foreach ($json as $propertyName => $propertyValue) { - if (is_int($propertyName)) { - return true; - } - } - - return false; + return new $className(...$constructorArguments); } /** - * Returns TRUE if the given JSON is a plain array or object. + * Checks whether the property declares the ReplaceNullWithDefaultValue attribute. * - * @param mixed $json + * @param class-string $className Fully qualified class containing the property to inspect. + * @param string $propertyName Property name that may carry the attribute. * - * @return bool + * @return bool True when null inputs should be replaced with the property's default value. */ - private function isIterableWithArraysOrObjects(mixed $json): bool + private function isReplaceNullWithDefaultValueAnnotation(string $className, string $propertyName): 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; - } - - foreach ($json as $propertyValue) { - if (is_array($propertyValue)) { - continue; - } - - if (is_object($propertyValue)) { - continue; - } + $reflectionProperty = $this->getReflectionProperty($className, $propertyName); + if (!($reflectionProperty instanceof ReflectionProperty)) { return false; } - return true; + return $this->hasAttribute($reflectionProperty, ReplaceNullWithDefaultValue::class); } /** - * Assert that the given classes exist. + * Builds the mapping of legacy property names to their replacements declared via attributes. * - * @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 + * @param class-string $className Fully qualified class inspected for ReplaceProperty attributes. * - * @throws InvalidArgumentException + * @return array Map of original property names to their replacement names. */ - private function assertClassesExists(string $className, ?string $collectionClassName = null): void + private function buildReplacePropertyMap(string $className): array { - if (!class_exists($className)) { - throw new InvalidArgumentException(sprintf('Class [%s] does not exist', $className)); - } + $reflectionClass = $this->getReflectionClass($className); - if ($collectionClassName === null) { - return; + if (!($reflectionClass instanceof ReflectionClass)) { + return []; } - if (class_exists($collectionClassName)) { - return; + $map = []; + $attributes = $reflectionClass->getAttributes(ReplaceProperty::class, ReflectionAttribute::IS_INSTANCEOF); + + foreach ($attributes as $attribute) { + /** @var ReplaceProperty $instance */ + $instance = $attribute->newInstance(); + $map[$instance->replaces] = $instance->value; } - throw new InvalidArgumentException(sprintf('Class [%s] does not exist', $collectionClassName)); + return $map; } /** - * Sets a property value. + * 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. * - * @param object $entity - * @param string $name - * @param mixed $value + * @return bool True when at least one matching attribute is present. */ - private function setProperty(object $entity, string $name, mixed $value): void + private function hasAttribute(ReflectionProperty $property, string $attributeClass): bool { - // Handle variadic setters - if (is_array($value)) { - $methodName = 'set' . ucfirst($name); - - if (method_exists($entity, $methodName)) { - $method = new ReflectionMethod($entity, $methodName); - $parameters = $method->getParameters(); + return $property->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF) !== []; + } - if ((count($parameters) === 1) && $parameters[0]->isVariadic()) { - $callable = [$entity, $methodName]; + /** + * Normalizes the property name using annotations and converters. + * + * @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 + { + $normalized = $propertyName; - if (is_callable($callable)) { - call_user_func_array($callable, $value); - } + if ( + is_string($normalized) + && array_key_exists($normalized, $replacePropertyMap) + ) { + $normalized = $replacePropertyMap[$normalized]; + } - return; - } - } + if ( + is_string($normalized) + && ($this->nameConverter instanceof PropertyNameConverterInterface) + ) { + return $this->nameConverter->convert($normalized); } - $this->accessor->setValue($entity, $name, $value); + return $normalized; } /** - * Get all public properties for the specified class. + * Converts arrays and objects into a plain array structure. * - * @param string $className The name of the class used to extract the properties + * @param array|object $json Source payload that may be an array, object, or traversable. * - * @return string[] + * @return array Normalised array representation of the provided payload. */ - private function getProperties(string $className): array + private function toIterableArray(array|object $json): array { - return $this->extractor->getProperties($className) ?? []; + if ($json instanceof Traversable) { + return iterator_to_array($json); + } + + if (is_object($json)) { + return get_object_vars($json); + } + + return $json; } /** - * Determine the type for the specified property using reflection. + * Returns the specified reflection property. * - * @param string $className The name of the class used to extract the property type info - * @param string $propertyName The name of the property + * @param class-string $className Fully qualified class containing the property definition. + * @param string $propertyName Property name resolved on the reflected class. * - * @return Type + * @return ReflectionProperty|null Reflection property instance when the property exists, null otherwise. */ - private function getType(string $className, string $propertyName): Type + private function getReflectionProperty(string $className, string $propertyName): ?ReflectionProperty { - $extractedType = $this->extractor->getType($className, $propertyName) ?? $this->defaultType; - - if ($extractedType instanceof UnionType) { - return $extractedType->getTypes()[0]; + try { + return new ReflectionProperty($className, $propertyName); + } catch (ReflectionException) { + return null; } - - return $extractedType; } /** - * Get the value for the specified node. - * - * @param mixed $json - * @param Type $type + * Returns the specified reflection class. * - * @return mixed|null + * @param class-string $className Fully qualified class name that should be reflected. * - * @throws DomainException + * @return ReflectionClass|null Reflection of the class when it exists, otherwise null. */ - private function getValue(mixed $json, Type $type): mixed + private function getReflectionClass(string $className): ?ReflectionClass { - 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) { + if (!class_exists($className)) { return null; } - if ($type instanceof ObjectType) { - return $this->asObject($json, $type); - } - - if ($type instanceof BuiltinType) { - settype($json, $type->getTypeIdentifier()->value); - } - - return $json; + return new ReflectionClass($className); } /** - * 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 + * Returns the default value of a property. * - * @return class-string + * @param class-string $className Fully qualified class that defines the property. + * @param string $propertyName Property name whose default value should be retrieved. * - * @throws DomainException + * @return mixed Default value configured on the property, or null when none exists. */ - private function getMappedClassName(string $className, mixed $json): string + private function getDefaultValue(string $className, string $propertyName): mixed { - if (array_key_exists($className, $this->classMap)) { - $classNameOrClosure = $this->classMap[$className]; - - if (!($classNameOrClosure instanceof Closure)) { - /** @var class-string $classNameOrClosure */ - return $classNameOrClosure; - } + $reflectionProperty = $this->getReflectionProperty($className, $propertyName); - // Execute closure to get the mapped class name - $className = $classNameOrClosure($json); + if (!($reflectionProperty instanceof ReflectionProperty)) { + return null; } - /** @var class-string $className */ - return $className; + return $reflectionProperty->getDefaultValue(); } /** - * Returns the class name. - * - * @param mixed $json - * @param ObjectType $type + * Returns TRUE if the given JSON contains integer property keys. * - * @return class-string + * @param array|object $json Source payload inspected for numeric keys. * - * @throws DomainException + * @return bool True when at least one numeric index is present. */ - private function getClassName(mixed $json, ObjectType $type): string + private function isNumericIndexArray(array|object $json): bool { - return $this->getMappedClassName( - $type->getClassName(), - $json - ); + foreach (array_keys($this->toIterableArray($json)) as $propertyName) { + if (is_int($propertyName)) { + return true; + } + } + + return false; } /** - * Cast node to a collection. - * - * @param array|object|null $json - * @param Type $type - * - * @return mixed[]|null - * - * @throws DomainException + * Returns TRUE if the given JSON is a plain array or object. */ - private function asCollection(array|object|null $json, Type $type): ?array + private function isIterableWithArraysOrObjects(mixed $json): bool { - if ($json === null) { - return null; + if ( + !is_array($json) + && !is_object($json) + ) { + return false; } - $collection = []; + $values = is_array($json) ? $json : $this->toIterableArray($json); + + foreach ($values as $propertyValue) { + if (is_array($propertyValue)) { + continue; + } + + if (is_object($propertyValue)) { + continue; + } - foreach ($json as $key => $value) { - $collection[$key] = $this->getValue($value, $type); + return false; } - return $collection; + return true; } /** - * Cast node to object. - * - * @param mixed $json - * @param ObjectType $type - * - * @return mixed|null - * - * @throws DomainException + * Assert that the given classes exist. */ - private function asObject(mixed $json, ObjectType $type): mixed + private function assertClassesExists(?string $className, ?string $collectionClassName = null): void { - /** @var class-string $className */ - $className = $this->getClassName($json, $type); + if ( + ($className !== null) + && !class_exists($className) + ) { + throw new InvalidArgumentException( + sprintf( + 'Class [%s] does not exist', + $className + ) + ); + } - if ($this->isCustomType($className)) { - return $this->callCustomClosure($json, $className); + if ($collectionClassName === null) { + return; } - return $this->map($json, $className); + if (class_exists($collectionClassName)) { + return; + } + + throw new InvalidArgumentException( + sprintf( + 'Class [%s] does not exist', + $collectionClassName + ) + ); } /** - * Determine if the specified type is a custom type. - * - * @template T - * - * @param class-string $typeClassName - * - * @return bool + * Sets a property value. */ - private function isCustomType(string $typeClassName): bool - { - return array_key_exists($typeClassName, $this->types); + 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); + + if (method_exists($entity, $methodName)) { + $method = new ReflectionMethod($entity, $methodName); + $parameters = $method->getParameters(); + + if ((count($parameters) === 1) && $parameters[0]->isVariadic()) { + $callable = [$entity, $methodName]; + + if (is_callable($callable)) { + call_user_func_array($callable, $value); + } + + return; + } + } + } + + $this->accessor->setValue($entity, $name, $value); } /** - * Call the custom closure for the specified type. - * - * @template T + * Get all public properties for the specified class. * - * @param mixed $json - * @param class-string $typeClassName + * @param class-string $className Fully qualified class whose property names should be extracted. * - * @return mixed + * @return string[] List of property names exposed by the configured extractor. */ - private function callCustomClosure(mixed $json, string $typeClassName): mixed + private function getProperties(string $className): array { - $callback = $this->types[$typeClassName]; - - return $callback($json); + return $this->extractor->getProperties($className) ?? []; } } 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 @@ -|BuiltinType|ObjectType + */ +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(), + private readonly PhpDocTypeHelper $phpDocTypeHelper = new PhpDocTypeHelper(), + ) { + if (!class_exists(DocBlockFactory::class)) { + throw new LogicException( + sprintf( + 'Unable to use %s without the "phpdocumentor/reflection-docblock" package. Please run "composer require phpdocumentor/reflection-docblock".', + self::class, + ), + ); + } + + $this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); + } + + /** + * Attempts to resolve a {@see CollectionType} from the collection class PHPDoc. + * + * @param class-string $collectionClassName Fully qualified class name of the collection wrapper to inspect. + * + * @return CollectionType>|null Resolved collection metadata or null when no matching PHPDoc is available. + */ + 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 new file mode 100644 index 0000000..47c78a8 --- /dev/null +++ b/src/JsonMapper/Collection/CollectionFactory.php @@ -0,0 +1,148 @@ +|BuiltinType|ObjectType + * + * @implements CollectionFactoryInterface + */ +final readonly class CollectionFactory implements CollectionFactoryInterface +{ + /** + * @param Closure(class-string, array|null):object $instantiator + */ + public function __construct( + private ValueConverter $valueConverter, + private ClassResolver $classResolver, + private Closure $instantiator, + ) { + } + + /** + * 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. + * + * @return array|null Normalised collection data or null when conversion fails. + */ + public function mapIterable(mixed $json, Type $valueType, MappingContext $context): ?array + { + if ($json === null) { + if ($context->shouldTreatNullAsEmptyCollection()) { + return []; + } + + return null; + } + + $source = match (true) { + $json instanceof Traversable => iterator_to_array($json), + is_array($json) => $json, + is_object($json) => get_object_vars($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) { + $collection[$key] = $context->withPathSegment((string) $key, fn (MappingContext $childContext): mixed => $this->valueConverter->convert($value, $valueType, $childContext)); + } + + return $collection; + } + + /** + * 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. + * + * @return object|array|null Instantiated collection wrapper or the normalised array values. + */ + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): array|object|null + { + $collection = $this->mapIterable($json, $type->getCollectionValueType(), $context); + + $wrappedType = $type->getWrappedType(); + + if (($wrappedType instanceof WrappingTypeInterface) && ($wrappedType->getWrappedType() instanceof ObjectType)) { + $objectType = $wrappedType->getWrappedType(); + $className = $this->resolveWrappedClass($objectType); + $resolvedClass = $this->classResolver->resolve($className, $json, $context); + + $instantiator = $this->instantiator; + + return $instantiator($resolvedClass, $collection); + } + + return $collection; + } + + /** + * Resolves the wrapped collection class name. + * + * @param ObjectType $objectType + * + * @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/Collection/CollectionFactoryInterface.php b/src/JsonMapper/Collection/CollectionFactoryInterface.php new file mode 100644 index 0000000..7bcb51d --- /dev/null +++ b/src/JsonMapper/Collection/CollectionFactoryInterface.php @@ -0,0 +1,53 @@ +|BuiltinType|ObjectType + */ +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. + * + * @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 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. + */ + public function fromCollectionType(CollectionType $type, mixed $json, MappingContext $context): mixed; +} diff --git a/src/JsonMapper/Configuration/JsonMapperConfiguration.php b/src/JsonMapper/Configuration/JsonMapperConfiguration.php new file mode 100644 index 0000000..c29da28 --- /dev/null +++ b/src/JsonMapper/Configuration/JsonMapperConfiguration.php @@ -0,0 +1,320 @@ + $data Configuration values indexed by property name + * + * @return self Configuration populated with the provided overrides + */ + 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. + * + * @return self Configuration aligned with the supplied context options + */ + public static function fromContext(MappingContext $context): self + { + return new self( + $context->isStrictMode(), + $context->shouldCollectErrors(), + (bool) $context->getOption(MappingContext::OPTION_TREAT_EMPTY_STRING_AS_NULL, false), + $context->shouldIgnoreUnknownProperties(), + $context->shouldTreatNullAsEmptyCollection(), + $context->getDefaultDateFormat(), + $context->shouldAllowScalarToObjectCasting(), + ); + } + + /** + * Serializes the configuration into an array representation. + * + * @return array Scalar configuration flags indexed by option name + */ + 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 Mapping context option bag compatible with {@see MappingContext} + */ + 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. + * + * @return bool True when unknown or missing properties are treated as failures + */ + public function isStrictMode(): bool + { + return $this->strictMode; + } + + /** + * Indicates whether errors should be collected during mapping. + * + * @return bool True when mapper should aggregate errors instead of failing fast + */ + public function shouldCollectErrors(): bool + { + return $this->collectErrors; + } + + /** + * 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 + { + return $this->emptyStringIsNull; + } + + /** + * Indicates whether unknown properties should be ignored. + * + * @return bool True when incoming properties without a target counterpart are skipped + */ + public function shouldIgnoreUnknownProperties(): bool + { + return $this->ignoreUnknownProperties; + } + + /** + * 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 + { + return $this->treatNullAsEmptyCollection; + } + + /** + * Returns the default date format used for date conversions. + * + * @return string Date format string compatible with {@see DateTimeInterface::format()} + */ + public function getDefaultDateFormat(): string + { + return $this->defaultDateFormat; + } + + /** + * Indicates whether scalar values may be cast to objects. + * + * @return bool True when scalar-to-object coercion should be attempted + */ + public function shouldAllowScalarToObjectCasting(): bool + { + return $this->allowScalarToObjectCasting; + } + + /** + * 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 + { + $clone = clone $this; + $clone->strictMode = $enabled; + + return $clone; + } + + /** + * 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 + { + $clone = clone $this; + $clone->collectErrors = $collect; + + return $clone; + } + + /** + * 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 + { + $clone = clone $this; + $clone->emptyStringIsNull = $enabled; + + return $clone; + } + + /** + * 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 + { + $clone = clone $this; + $clone->ignoreUnknownProperties = $enabled; + + return $clone; + } + + /** + * 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 + { + $clone = clone $this; + $clone->treatNullAsEmptyCollection = $enabled; + + return $clone; + } + + /** + * 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 + { + $clone = clone $this; + $clone->defaultDateFormat = $format; + + return $clone; + } + + /** + * 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 + { + $clone = clone $this; + $clone->allowScalarToObjectCasting = $enabled; + + return $clone; + } +} diff --git a/src/JsonMapper/Context/MappingContext.php b/src/JsonMapper/Context/MappingContext.php new file mode 100644 index 0000000..fc7f7c0 --- /dev/null +++ b/src/JsonMapper/Context/MappingContext.php @@ -0,0 +1,282 @@ + + */ + private array $pathSegments = []; + + /** + * @var list + */ + private array $errorRecords = []; + + /** + * @var array + */ + private array $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 = []) + { + $this->options = $options; + } + + /** + * Returns the root JSON input value. + * + * @return mixed Original payload that initiated the current mapping run + */ + public function getRootInput(): mixed + { + return $this->rootInput; + } + + /** + * Returns the current path inside the JSON structure. + * + * @return string Dot-separated path beginning with the root symbol + */ + public function getPath(): string + { + if ($this->pathSegments === []) { + return '$'; + } + + return '$.' . implode('.', $this->pathSegments); + } + + /** + * Executes the callback while appending the provided segment to the path. + * + * @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 + { + $this->pathSegments[] = (string) $segment; + + try { + return $callback($this); + } finally { + array_pop($this->pathSegments); + } + } + + /** + * 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 + { + if (!$this->shouldCollectErrors()) { + return; + } + + $this->errorRecords[] = new MappingError($this->getPath(), $message, $exception); + } + + /** + * Stores the exception and message for later consumption. + * + * @param MappingException $exception Exception raised during mapping + * + * @return void + */ + public function recordException(MappingException $exception): void + { + $this->addError($exception->getMessage(), $exception); + } + + /** + * Returns collected mapping errors. + * + * @return list Error messages collected so far + */ + public function getErrors(): array + { + return array_map( + static fn (MappingError $error): string => $error->getMessage(), + $this->errorRecords, + ); + } + + /** + * 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; + + if (!is_string($format) || $format === '') { + return DateTimeInterface::ATOM; + } + + 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); + } + + /** + * Returns all options. + * + * @return array Associative array of context options + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * 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 + { + return $this->options[$name] ?? $default; + } + + /** + * Replaces the stored options. + * + * @param array $options Complete set of options to store + * + * @return void + */ + public function replaceOptions(array $options): void + { + $this->options = $options; + } + + /** + * Returns collected mapping errors with contextual details. + * + * @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 new file mode 100644 index 0000000..24b9e49 --- /dev/null +++ b/src/JsonMapper/Context/MappingError.php @@ -0,0 +1,62 @@ +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; + } +} diff --git a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php index e57aba3..63eacff 100644 --- a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php +++ b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php @@ -21,15 +21,14 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -class CamelCasePropertyNameConverter implements PropertyNameConverterInterface +final readonly class CamelCasePropertyNameConverter implements PropertyNameConverterInterface { - /** - * @var Inflector - */ private Inflector $inflector; /** - * CamelCasePropertyNameConverter constructor. + * 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() { @@ -37,11 +36,11 @@ public function __construct() } /** - * Convert the specified JSON property name to its PHP property name. + * Converts a raw JSON property name to the camelCase variant expected by PHP properties. * - * @param string $name + * @param string $name Raw property name as provided by the JSON payload. * - * @return string + * @return string Normalised camelCase property name that matches PHP naming conventions. */ public function convert(string $name): string { 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; } diff --git a/src/JsonMapper/Exception/CollectionMappingException.php b/src/JsonMapper/Exception/CollectionMappingException.php new file mode 100644 index 0000000..30add6e --- /dev/null +++ b/src/JsonMapper/Exception/CollectionMappingException.php @@ -0,0 +1,45 @@ +actualType; + } +} diff --git a/src/JsonMapper/Exception/MappingException.php b/src/JsonMapper/Exception/MappingException.php new file mode 100644 index 0000000..f79be6b --- /dev/null +++ b/src/JsonMapper/Exception/MappingException.php @@ -0,0 +1,49 @@ +path; + } +} diff --git a/src/JsonMapper/Exception/MissingPropertyException.php b/src/JsonMapper/Exception/MissingPropertyException.php new file mode 100644 index 0000000..d6d1b99 --- /dev/null +++ b/src/JsonMapper/Exception/MissingPropertyException.php @@ -0,0 +1,61 @@ +propertyName; + } + + /** + * 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 + { + return $this->className; + } +} diff --git a/src/JsonMapper/Exception/ReadonlyPropertyException.php b/src/JsonMapper/Exception/ReadonlyPropertyException.php new file mode 100644 index 0000000..80c2687 --- /dev/null +++ b/src/JsonMapper/Exception/ReadonlyPropertyException.php @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..0564f1d --- /dev/null +++ b/src/JsonMapper/Exception/UnknownPropertyException.php @@ -0,0 +1,62 @@ +propertyName; + } + + /** + * 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 + { + return $this->className; + } +} diff --git a/src/JsonMapper/Report/MappingReport.php b/src/JsonMapper/Report/MappingReport.php new file mode 100644 index 0000000..deff5d5 --- /dev/null +++ b/src/JsonMapper/Report/MappingReport.php @@ -0,0 +1,57 @@ + $errors + */ + public function __construct(private array $errors) + { + } + + /** + * @return list + */ + 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 new file mode 100644 index 0000000..b0e760d --- /dev/null +++ b/src/JsonMapper/Report/MappingResult.php @@ -0,0 +1,44 @@ +value; + } + + /** + * Provides the report with the diagnostics gathered during mapping. + */ + public function getReport(): MappingReport + { + return $this->report; + } +} diff --git a/src/JsonMapper/Resolver/ClassResolver.php b/src/JsonMapper/Resolver/ClassResolver.php new file mode 100644 index 0000000..465279d --- /dev/null +++ b/src/JsonMapper/Resolver/ClassResolver.php @@ -0,0 +1,166 @@ + + * + * @phpstan-var array + */ + private array $classMap; + + /** + * @param array $classMap Map of base class names to explicit targets or resolver callbacks. + * + * @phpstan-param array $classMap + */ + public function __construct(array $classMap = []) + { + $this->classMap = $this->validateClassMap($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. + * + * @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 + { + $this->assertClassString($className); + $this->classMap[$className] = $resolver; + } + + /** + * 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. + * + * @return class-string Fully-qualified class name that should be instantiated for the payload. + */ + public function resolve(string $className, mixed $json, MappingContext $context): string + { + if (!array_key_exists($className, $this->classMap)) { + return $this->assertClassString($className); + } + + $mapped = $this->classMap[$className]; + + if (!($mapped instanceof Closure)) { + return $this->assertClassString($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 $this->assertClassString($resolved); + } + + /** + * 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); + } + + return $resolver($json); + } + + /** + * 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. + * + * @return array Sanitised map ready for runtime lookups. + * + * @throws DomainException When a class key or mapped class name is empty or cannot be autoloaded. + */ + private function validateClassMap(array $classMap): array + { + foreach ($classMap as $sourceClass => $mapping) { + $this->assertClassString($sourceClass); + + if ($mapping instanceof Closure) { + continue; + } + + $this->assertClassString($mapping); + } + + return $classMap; + } + + /** + * 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 When the name is empty or cannot be resolved by the autoloader. + */ + 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 new file mode 100644 index 0000000..3866a11 --- /dev/null +++ b/src/JsonMapper/Type/TypeResolver.php @@ -0,0 +1,295 @@ + + */ + 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 === null) { + $type = $this->resolveFromReflection($className, $propertyName); + } + + $resolved = $type instanceof Type ? $this->normalizeType($type) : $this->defaultType; + + $this->storeCachedType($className, $propertyName, $resolved); + + 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) { + return $this->normalizeUnionType($type); + } + + return $type; + } + + /** + * 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 instanceof CacheItemPoolInterface) { + return null; + } + + try { + $item = $this->cache->getItem($this->buildCacheKey($className, $propertyName)); + } catch (CacheInvalidArgumentException) { + return null; + } + + 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 instanceof CacheItemPoolInterface) { + 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 . str_replace('\\', '_', $className) . '.' . $propertyName; + } + + /** + * 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 + { + 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) { + $types = []; + $allowsNull = false; + + foreach ($reflectionType->getTypes() as $innerType) { + if (!$innerType instanceof ReflectionNamedType) { + continue; + } + + if ($innerType->getName() === 'null') { + $allowsNull = true; + + continue; + } + + $resolved = $this->createTypeFromNamedReflection($innerType); + + if ($resolved instanceof Type) { + $types[] = $resolved; + } + } + + 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; + } + + /** + * 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(); + + 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; + } + + /** + * 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 + { + $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; + } + + /** + * 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; + } +} diff --git a/src/JsonMapper/Value/ClosureTypeHandler.php b/src/JsonMapper/Value/ClosureTypeHandler.php new file mode 100644 index 0000000..e47ff76 --- /dev/null +++ b/src/JsonMapper/Value/ClosureTypeHandler.php @@ -0,0 +1,88 @@ +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) { + return false; + } + + 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)) { + throw new LogicException(sprintf('Handler does not support type %s.', $type::class)); + } + + return ($this->converter)($value, $context); + } + + /** + * 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 + { + $closure = $converter instanceof Closure ? $converter : Closure::fromCallable($converter); + $reflection = new ReflectionFunction($closure); + + if ($reflection->getNumberOfParameters() >= 2) { + 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); + } +} diff --git a/src/JsonMapper/Value/CustomTypeRegistry.php b/src/JsonMapper/Value/CustomTypeRegistry.php new file mode 100644 index 0000000..8abbf4e --- /dev/null +++ b/src/JsonMapper/Value/CustomTypeRegistry.php @@ -0,0 +1,93 @@ + + */ + private array $handlers = []; + + /** + * Registers the converter for the provided class name. + * + * @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 + */ + public function register(string $className, callable $converter): void + { + $this->registerHandler(new ClosureTypeHandler($className, $converter)); + } + + /** + * 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 + { + $this->handlers[] = $handler; + } + + /** + * 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 + { + foreach ($this->handlers as $handler) { + if ($handler->supports($type, $value)) { + return true; + } + } + + return false; + } + + /** + * 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 + { + foreach ($this->handlers as $handler) { + if ($handler->supports($type, $value)) { + return $handler->convert($type, $value, $context); + } + } + + throw new LogicException(sprintf('No custom type handler registered for %s.', $type::class)); + } +} diff --git a/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php new file mode 100644 index 0000000..4071d39 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/BuiltinValueConversionStrategy.php @@ -0,0 +1,226 @@ +normalizeValue($value, $type); + + $this->guardCompatibility($normalized, $type, $context); + + if ($normalized === null) { + return null; + } + + $converted = $normalized; + settype($converted, $type->getTypeIdentifier()->value); + + return $converted; + } + + /** + * 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 + { + 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; + } + + /** + * 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 + { + $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; + } + } + + /** + * 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) { + '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_iterable($value), + 'null' => $value === null, + default => true, + }; + } +} diff --git a/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php new file mode 100644 index 0000000..789b954 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/CollectionValueConversionStrategy.php @@ -0,0 +1,65 @@ + $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); + + return $this->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..9ab0bc7 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/CustomTypeValueConversionStrategy.php @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..dc771a5 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/DateTimeValueConversionStrategy.php @@ -0,0 +1,107 @@ +extractObjectType($type); + + if (!$objectType instanceof ObjectType) { + return false; + } + + $className = $objectType->getClassName(); + + 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( + $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)); + } + } + ); + } +} diff --git a/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php new file mode 100644 index 0000000..03dc169 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php @@ -0,0 +1,91 @@ +extractObjectType($type); + + if (!$objectType instanceof ObjectType) { + return false; + } + + $className = $objectType->getClassName(); + + if (!enum_exists($className)) { + return false; + } + + 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( + $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; + } + ); + } +} diff --git a/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php new file mode 100644 index 0000000..0e98065 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php @@ -0,0 +1,49 @@ +|null Object type when the metadata targets a concrete class; otherwise null. + */ + private function extractObjectType(Type $type): ?ObjectType + { + if (!($type instanceof ObjectType)) { + return null; + } + + if ($type->getClassName() === '') { + return null; + } + + return $type; + } + + /** + * 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. + * + * @return void + */ + 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 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 + { + $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 new file mode 100644 index 0000000..de32da8 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/ObjectValueConversionStrategy.php @@ -0,0 +1,107 @@ +resolveClassName($type); + $resolvedClass = $this->classResolver->resolve($className, $value, $context); + + 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; + } + } + + $mapper = $this->mapper; + + return $mapper($value, $resolvedClass, $context); + } + + /** + * Resolves the class name from the provided object type. + * + * @param ObjectType $type Object type metadata describing the target property. + * + * @return class-string Concrete class name extracted from the metadata. + */ + 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/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php new file mode 100644 index 0000000..c6801d6 --- /dev/null +++ b/src/JsonMapper/Value/Strategy/PassthroughValueConversionStrategy.php @@ -0,0 +1,49 @@ + + */ + private array $strategies = []; + + /** + * 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 + { + $this->strategies[] = $strategy; + } + + /** + * 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 + { + 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/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/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/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/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 @@ + + */ 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 { } 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/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/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 @@ + * @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 @@ +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..51b0901 --- /dev/null +++ b/tests/Fixtures/Cache/InMemoryCachePool.php @@ -0,0 +1,134 @@ + + */ + private array $items = []; + + private int $saveCalls = 0; + + 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; + } + + return $item; + } + + $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) { + $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/Fixtures/Enum/SampleStatus.php b/tests/Fixtures/Enum/SampleStatus.php new file mode 100644 index 0000000..a807865 --- /dev/null +++ b/tests/Fixtures/Enum/SampleStatus.php @@ -0,0 +1,18 @@ +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/Context/MappingContextTest.php b/tests/JsonMapper/Context/MappingContextTest.php new file mode 100644 index 0000000..c71339d --- /dev/null +++ b/tests/JsonMapper/Context/MappingContextTest.php @@ -0,0 +1,78 @@ +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')); + } + + #[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/JsonMapper/DocsCustomNameConverterTest.php b/tests/JsonMapper/DocsCustomNameConverterTest.php new file mode 100644 index 0000000..91f0d25 --- /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..5af7091 --- /dev/null +++ b/tests/JsonMapper/DocsNestedCollectionsTest.php @@ -0,0 +1,74 @@ +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::assertTrue($articles->offsetExists(0)); + + $article = $articles[0]; + self::assertInstanceOf(Article::class, $article); + + /** @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::assertTrue($firstRow->offsetExists(0)); + + $firstTag = $firstRow[0]; + self::assertInstanceOf(Tag::class, $firstTag); + self::assertSame('php', $firstTag->name); + + self::assertTrue($tags->offsetExists(1)); + $secondRow = $tags[1]; + self::assertInstanceOf(TagCollection::class, $secondRow); + self::assertCount(1, $secondRow); + self::assertContainsOnlyInstancesOf(Tag::class, $secondRow); + 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 new file mode 100644 index 0000000..bd89f99 --- /dev/null +++ b/tests/JsonMapper/DocsQuickStartTest.php @@ -0,0 +1,72 @@ +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); + + /** @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::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/JsonMapperErrorHandlingTest.php b/tests/JsonMapper/JsonMapperErrorHandlingTest.php new file mode 100644 index 0000000..f8d4e38 --- /dev/null +++ b/tests/JsonMapper/JsonMapperErrorHandlingTest.php @@ -0,0 +1,276 @@ +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, + JsonMapperConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnMissingRequiredProperties(): void + { + $this->expectException(MissingPropertyException::class); + + $this->getJsonMapper() + ->map( + [], + Person::class, + null, + null, + JsonMapperConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnTypeMismatch(): void + { + $this->expectException(TypeMismatchException::class); + + $this->getJsonMapper() + ->map( + ['name' => 123], + Base::class, + null, + null, + JsonMapperConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsOnInvalidCollectionPayloads(): void + { + $this->expectException(CollectionMappingException::class); + + $this->getJsonMapper() + ->map( + [ + 'name' => 'John Doe', + 'simpleArray' => 'invalid', + ], + Base::class, + null, + null, + JsonMapperConfiguration::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 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 + { + $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, + JsonMapperConfiguration::strict(), + ); + } + + #[Test] + public function itThrowsWhenRequiredPropertyIsNullInStrictMode(): void + { + $this->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/JsonMapperFactoryTest.php b/tests/JsonMapper/JsonMapperFactoryTest.php new file mode 100644 index 0000000..5724a77 --- /dev/null +++ b/tests/JsonMapper/JsonMapperFactoryTest.php @@ -0,0 +1,54 @@ + 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); + } +} 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); + } +} diff --git a/tests/JsonMapper/JsonMapperTypeCacheTest.php b/tests/JsonMapper/JsonMapperTypeCacheTest.php new file mode 100644 index 0000000..7768893 --- /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()); + } +} 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()); + } +} diff --git a/tests/JsonMapper/Resolver/ClassResolverTest.php b/tests/JsonMapper/Resolver/ClassResolverTest.php new file mode 100644 index 0000000..fc68ce5 --- /dev/null +++ b/tests/JsonMapper/Resolver/ClassResolverTest.php @@ -0,0 +1,81 @@ + DummyMappedClass::class]); + $context = new MappingContext([]); + + self::assertSame(DummyMappedClass::class, $resolver->resolve(DummyBaseClass::class, ['json'], $context)); + } + + #[Test] + public function itSupportsClosuresWithSingleArgument(): void + { + $resolver = new ClassResolver([DummyBaseClass::class => static fn (): string => DummyMappedClass::class]); + $context = new MappingContext([]); + + self::assertSame(DummyMappedClass::class, $resolver->resolve(DummyBaseClass::class, ['json'], $context)); + } + + #[Test] + public function itSupportsClosuresReceivingContext(): void + { + $resolver = new ClassResolver([ + DummyBaseClass::class => static function (mixed $json, MappingContext $context): string { + $context->addError('accessed'); + + return DummyResolvedClass::class; + }, + ]); + $context = new MappingContext([], ['flag' => true]); + + 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/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 +{ +} diff --git a/tests/JsonMapper/Value/CustomTypeRegistryTest.php b/tests/JsonMapper/Value/CustomTypeRegistryTest.php new file mode 100644 index 0000000..9598d59 --- /dev/null +++ b/tests/JsonMapper/Value/CustomTypeRegistryTest.php @@ -0,0 +1,88 @@ +register('Foo', static fn (mixed $value): array => (array) $value); + + $context = new MappingContext([]); + $type = new ObjectType('Foo'); + + self::assertTrue($registry->supports($type, ['bar' => 'baz'])); + self::assertSame(['bar' => 'baz'], $registry->convert($type, ['bar' => 'baz'], $context)); + } + + #[Test] + public function itPassesContextToConverters(): void + { + $registry = new CustomTypeRegistry(); + $registry->register('Foo', static function (mixed $value, MappingContext $context): array { + $context->addError('called'); + + return (array) $value; + }); + + $context = new MappingContext([]); + $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 e2d8f80..ee78f2b 100644 --- a/tests/JsonMapperTest.php +++ b/tests/JsonMapperTest.php @@ -11,21 +11,32 @@ namespace MagicSunday\Test; +use DateInterval; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; +use MagicSunday\JsonMapper\Exception\UnknownPropertyException; +use MagicSunday\JsonMapper\Value\ClosureTypeHandler; 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; 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; @@ -81,6 +92,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. */ @@ -219,27 +252,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), @@ -528,11 +563,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 +793,193 @@ 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 = JsonMapperConfiguration::lenient()->withEmptyStringAsNull(true); + + $result = $this->getJsonMapper() + ->map( + ['value' => ''], + NullableStringHolder::class, + null, + null, + $configuration, + ); + + self::assertInstanceOf(NullableStringHolder::class, $result); + self::assertNull($result->value); + } + + #[Test] + public function itAppliesConfiguredStrictModeByDefault(): void + { + $config = (new JsonMapperConfiguration())->withStrictMode(true); + + $this->expectException(UnknownPropertyException::class); + + $this->getJsonMapper([], $config)->map( + [ + 'name' => 'John Doe', + 'unknown' => 'value', + ], + Person::class, + ); + } + + #[Test] + public function itIgnoresUnknownPropertiesWhenConfigured(): void + { + $config = (new JsonMapperConfiguration())->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 JsonMapperConfiguration())->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 JsonMapperConfiguration())->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 JsonMapperConfiguration())->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 7a91fa9..47415b1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,6 +14,7 @@ use Closure; use JsonException; use MagicSunday\JsonMapper; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -32,11 +33,10 @@ class TestCase extends \PHPUnit\Framework\TestCase /** * Returns an instance of the JsonMapper for testing. * - * @param string[]|Closure[] $classMap - * - * @return JsonMapper + * @param array $classMap + * @param JsonMapperConfiguration|null $config */ - protected function getJsonMapper(array $classMap = []): JsonMapper + protected function getJsonMapper(array $classMap = [], ?JsonMapperConfiguration $config = null): JsonMapper { $listExtractors = [new ReflectionExtractor()]; $typeExtractors = [new PhpDocExtractor()]; @@ -46,7 +46,9 @@ protected function getJsonMapper(array $classMap = []): JsonMapper $extractor, PropertyAccess::createPropertyAccessor(), new CamelCasePropertyNameConverter(), - $classMap + $classMap, + null, + $config ?? new JsonMapperConfiguration(), ); }