diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e41c407..6be2ffc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ permissions: env: PHP_VERSION: '8.5' - COMPOSER_ROOT_VERSION: '1.2.0' jobs: build: @@ -23,7 +22,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Validate composer.json @@ -53,7 +51,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Download vendor artifact from build @@ -78,7 +75,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Download vendor artifact from build diff --git a/composer.json b/composer.json index 3bbf9ac..ed3dd8e 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,6 @@ "issues": "https://github.com/tiny-blocks/mapper/issues", "source": "https://github.com/tiny-blocks/mapper" }, - "extra": { - "branch-alias": { - "dev-develop": "1.3.x-dev" - } - }, "config": { "sort-packages": true, "allow-plugins": { @@ -43,7 +38,7 @@ }, "autoload-dev": { "psr-4": { - "TinyBlocks\\Mapper\\": "tests/" + "Test\\TinyBlocks\\Mapper\\": "tests/" } }, "require": { @@ -53,8 +48,7 @@ "phpunit/phpunit": "^11.5", "phpstan/phpstan": "^2.1", "infection/infection": "^0.32", - "tiny-blocks/collection": "1.10.*", - "squizlabs/php_codesniffer": "^3.13" + "squizlabs/php_codesniffer": "^4.0" }, "scripts": { "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 05a92df..f699dd9 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,11 +4,11 @@ parameters: level: 9 tmpDir: report/phpstan ignoreErrors: - - '#method#' - - '#expects#' - - '#should return#' + - '#T of#' + - '#mixed#' + - '#UnitEnum#' + - '#Reflection#' + - '#Traversable#' - '#is used zero times#' - - '#type mixed supplied#' - - '#not specify its types#' - - '#no value type specified#' + - '#type specified in iterable type#' reportUnmatchedIgnoredErrors: false diff --git a/src/Internal/Builders/ObjectBuilder.php b/src/Internal/Builders/ObjectBuilder.php new file mode 100644 index 0000000..0576777 --- /dev/null +++ b/src/Internal/Builders/ObjectBuilder.php @@ -0,0 +1,98 @@ + $class + * @return T + * @throws ReflectionException + */ + public function build(iterable $iterable, string $class): object + { + $reflection = new ReflectionClass(objectOrClass: $class); + $parameters = $this->extractor->extractConstructorParameters(class: $class); + $inputProperties = iterator_to_array(iterator: $iterable); + + $arguments = $this->buildArguments( + parameters: $parameters, + inputProperties: $inputProperties + ); + + return $this->instantiate(reflection: $reflection, arguments: $arguments); + } + + protected function buildArguments(array $parameters, array $inputProperties): array + { + $arguments = []; + + /** @var ReflectionParameter $parameter */ + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $value = $inputProperties[$name] ?? null; + + $arguments[] = $value !== null + ? $this->castValue(parameter: $parameter, value: $value) + : $this->getDefaultValue(parameter: $parameter); + } + + return $arguments; + } + + protected function castValue(ReflectionParameter $parameter, mixed $value): mixed + { + $caster = new CasterHandler(parameter: $parameter); + return $caster->castValue(value: $value); + } + + protected function getDefaultValue(ReflectionParameter $parameter): mixed + { + return $parameter->isDefaultValueAvailable() + ? $parameter->getDefaultValue() + : null; + } + + protected function instantiate(ReflectionClass $reflection, array $arguments): object + { + $constructor = $reflection->getConstructor(); + + if ($constructor === null) { + return $reflection->newInstance(); + } + + if ($constructor->isPrivate()) { + return $this->instantiateWithPrivateConstructor( + reflection: $reflection, + constructor: $constructor, + arguments: $arguments + ); + } + + return $reflection->newInstanceArgs(args: $arguments); + } + + protected function instantiateWithPrivateConstructor( + ReflectionClass $reflection, + ReflectionMethod $constructor, + array $arguments + ): object { + $instance = $reflection->newInstanceWithoutConstructor(); + $constructor->invokeArgs(object: $instance, args: $arguments); + return $instance; + } +} diff --git a/src/Internal/Detectors/DateTimeDetector.php b/src/Internal/Detectors/DateTimeDetector.php new file mode 100644 index 0000000..0a84e4e --- /dev/null +++ b/src/Internal/Detectors/DateTimeDetector.php @@ -0,0 +1,15 @@ +getProperties( + ReflectionProperty::IS_PUBLIC + | ReflectionProperty::IS_PROTECTED + | ReflectionProperty::IS_PRIVATE + ); + + return !$value instanceof UnitEnum && count($properties) === self::SINGLE_PROPERTY; + } +} diff --git a/src/Internal/Exceptions/InvalidCast.php b/src/Internal/Exceptions/InvalidCast.php index 0cff8b5..f70e5a0 100644 --- a/src/Internal/Exceptions/InvalidCast.php +++ b/src/Internal/Exceptions/InvalidCast.php @@ -11,7 +11,6 @@ final class InvalidCast extends InvalidArgumentException public static function forEnumValue(int|string $value, string $class): InvalidCast { $message = sprintf('Invalid value <%s> for enum <%s>.', $value, $class); - return new InvalidCast(message: $message); } } diff --git a/src/Internal/Extractors/IterableExtractor.php b/src/Internal/Extractors/IterableExtractor.php new file mode 100644 index 0000000..7ada306 --- /dev/null +++ b/src/Internal/Extractors/IterableExtractor.php @@ -0,0 +1,28 @@ +extractor->extractProperties(object: $object); + + $candidates = array_filter( + $properties, + static fn(mixed $value): bool => is_array($value) || $value instanceof Traversable + ); + + $iterable = reset($candidates); + + return is_array($iterable) ? $iterable : iterator_to_array($iterable); + } +} diff --git a/src/Internal/Extractors/PropertyExtractor.php b/src/Internal/Extractors/PropertyExtractor.php new file mode 100644 index 0000000..fe73d71 --- /dev/null +++ b/src/Internal/Extractors/PropertyExtractor.php @@ -0,0 +1,19 @@ +getProperties( + ReflectionProperty::IS_PUBLIC + | ReflectionProperty::IS_PROTECTED + | ReflectionProperty::IS_PRIVATE + ); + + $extracted = []; + + foreach ($properties as $property) { + if ($property->isStatic()) { + continue; + } + + $name = $property->getName(); + $extracted[$name] = $property->getValue(object: $object); + } + + return $extracted; + } + + public function extractConstructorParameters(string $class): array + { + $reflection = new ReflectionClass(objectOrClass: $class); + $constructor = $reflection->getConstructor(); + + if ($constructor === null) { + return []; + } + + return $constructor->getParameters(); + } +} diff --git a/src/Internal/Extractors/ValuePropertyExtractor.php b/src/Internal/Extractors/ValuePropertyExtractor.php new file mode 100644 index 0000000..c37abe7 --- /dev/null +++ b/src/Internal/Extractors/ValuePropertyExtractor.php @@ -0,0 +1,15 @@ +getProperties()[0]->getValue($object); + } +} diff --git a/src/Internal/Factories/StrategyResolverFactory.php b/src/Internal/Factories/StrategyResolverFactory.php new file mode 100644 index 0000000..d7e6ec5 --- /dev/null +++ b/src/Internal/Factories/StrategyResolverFactory.php @@ -0,0 +1,69 @@ +set(resolver: $resolver); + + return $resolver; + } +} diff --git a/src/Internal/MappabilityBehavior.php b/src/Internal/MappabilityBehavior.php new file mode 100644 index 0000000..4299bff --- /dev/null +++ b/src/Internal/MappabilityBehavior.php @@ -0,0 +1,25 @@ +map(value: $this->toArray(keyPreservation: $keyPreservation)); + } + + public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array + { + $resolver = new StrategyResolverFactory()->create(); + + return new ArrayMapper(resolver: $resolver)->map(value: $this, keyPreservation: $keyPreservation); + } +} diff --git a/src/Internal/Mappers/ArrayMapper.php b/src/Internal/Mappers/ArrayMapper.php new file mode 100644 index 0000000..d4d05c9 --- /dev/null +++ b/src/Internal/Mappers/ArrayMapper.php @@ -0,0 +1,22 @@ +resolver + ->resolve(value: $value) + ->map(value: $value, keyPreservation: $keyPreservation); + } +} diff --git a/src/Internal/Mappers/Collection/ArrayMapper.php b/src/Internal/Mappers/Collection/ArrayMapper.php deleted file mode 100644 index bee2d5a..0000000 --- a/src/Internal/Mappers/Collection/ArrayMapper.php +++ /dev/null @@ -1,48 +0,0 @@ -valueIsCollectible(value: $value)) { - $collectionMapper = new CollectionMapper(valueMapper: $valueMapper); - return $collectionMapper->map(value: $value, keyPreservation: $keyPreservation); - } - - $reflectionClass = new ReflectionClass($value); - $shouldPreserveKeys = $keyPreservation->shouldPreserveKeys(); - - foreach ($reflectionClass->getProperties() as $property) { - $propertyValue = $property->getValue($value); - - $propertyValue = is_iterable($propertyValue) - ? iterator_to_array($propertyValue, $shouldPreserveKeys) - : $valueMapper->map(value: $propertyValue, keyPreservation: $keyPreservation); - - if (is_array($propertyValue)) { - $arrayMapper = fn(mixed $value): mixed => $valueMapper->map( - value: $value, - keyPreservation: $keyPreservation - ); - $propertyValue = array_map($arrayMapper, $propertyValue); - } - - $mappedValues[$property->getName()] = $valueMapper->map( - value: $propertyValue, - keyPreservation: $keyPreservation - ); - } - - return $shouldPreserveKeys ? $mappedValues : array_values($mappedValues); - } -} diff --git a/src/Internal/Mappers/Collection/CollectionMapper.php b/src/Internal/Mappers/Collection/CollectionMapper.php deleted file mode 100644 index 01455c8..0000000 --- a/src/Internal/Mappers/Collection/CollectionMapper.php +++ /dev/null @@ -1,26 +0,0 @@ - $element) { - $mappedValues[$key] = $this->valueMapper->map(value: $element, keyPreservation: $keyPreservation); - } - - return $keyPreservation->shouldPreserveKeys() ? $mappedValues : array_values($mappedValues); - } -} diff --git a/src/Internal/Mappers/Collection/DateTimeMapper.php b/src/Internal/Mappers/Collection/DateTimeMapper.php deleted file mode 100644 index c479d8a..0000000 --- a/src/Internal/Mappers/Collection/DateTimeMapper.php +++ /dev/null @@ -1,21 +0,0 @@ -getTimezone()->getOffset($value) !== self::UTC_OFFSET) { - return $value->format(DateTimeInterface::ATOM); - } - - return $value->format('Y-m-d H:i:s'); - } -} diff --git a/src/Internal/Mappers/Collection/EnumMapper.php b/src/Internal/Mappers/Collection/EnumMapper.php deleted file mode 100644 index 913a906..0000000 --- a/src/Internal/Mappers/Collection/EnumMapper.php +++ /dev/null @@ -1,16 +0,0 @@ -value : $value->name; - } -} diff --git a/src/Internal/Mappers/Collection/ValueMapper.php b/src/Internal/Mappers/Collection/ValueMapper.php deleted file mode 100644 index 2ac6fba..0000000 --- a/src/Internal/Mappers/Collection/ValueMapper.php +++ /dev/null @@ -1,31 +0,0 @@ - new EnumMapper()->map(value: $value), - is_a($value, DateTimeInterface::class) => new DateTimeMapper()->map(value: $value), - is_object($value) => new ArrayMapper()->map( - value: $value, - keyPreservation: $keyPreservation - ), - default => $value - }; - } - - public function valueIsCollectible(object $value): bool - { - return is_a($value, Collectible::class); - } -} diff --git a/src/Internal/Mappers/Json/JsonMapper.php b/src/Internal/Mappers/Json/JsonMapper.php deleted file mode 100644 index b2b9f2c..0000000 --- a/src/Internal/Mappers/Json/JsonMapper.php +++ /dev/null @@ -1,21 +0,0 @@ - $carry && empty($item), true); - }; - - if ($isAllEmpty(items: $value)) { - return '[]'; - } - - return json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE); - } -} diff --git a/src/Internal/Mappers/JsonMapper.php b/src/Internal/Mappers/JsonMapper.php new file mode 100644 index 0000000..e8d1a78 --- /dev/null +++ b/src/Internal/Mappers/JsonMapper.php @@ -0,0 +1,17 @@ +parameter->getType()->getName(); - + $typeName = $this->parameter->getType()->getName(); $caster = match (true) { - $class === Generator::class, => new GeneratorCaster(), - $class === ArrayIterator::class, => new ArrayIteratorCaster(), - is_subclass_of($class, UnitEnum::class) => new EnumCaster(class: $class), - is_subclass_of($class, Collectible::class) => new CollectionCaster(class: $class), - is_subclass_of($class, DateTimeInterface::class) => new DateTimeCaster(), - default => new DefaultCaster(class: $class) + $typeName === Generator::class => new GeneratorCaster(), + enum_exists($typeName) => new EnumCaster(class: $typeName), + default => new DefaultCaster(class: $typeName) }; return $caster->castValue(value: $value); diff --git a/src/Internal/Mappers/Object/Casters/CollectionCaster.php b/src/Internal/Mappers/Object/Casters/CollectionCaster.php deleted file mode 100644 index d98d01d..0000000 --- a/src/Internal/Mappers/Object/Casters/CollectionCaster.php +++ /dev/null @@ -1,38 +0,0 @@ -class); - /** @var IterableMapper & Collectible $instance */ - $instance = $reflectionClass->newInstanceWithoutConstructor(); - - $type = $instance->getType(); - - if ($type === $this->class) { - return $instance->createFrom(elements: $value); - } - - $mapped = []; - - foreach ($value as $item) { - $mapped[] = new ObjectMapper()->map(iterable: $item, class: $type); - } - - return $instance->createFrom(elements: $mapped); - } -} diff --git a/src/Internal/Mappers/Object/Casters/DateTimeCaster.php b/src/Internal/Mappers/Object/Casters/DateTimeCaster.php deleted file mode 100644 index 077d91e..0000000 --- a/src/Internal/Mappers/Object/Casters/DateTimeCaster.php +++ /dev/null @@ -1,15 +0,0 @@ -class)) { + if (!class_exists(class: $this->class) || $value instanceof $this->class) { return $value; } - return new ObjectMapper()->map(iterable: $value, class: $this->class); + return Reflector::reflectFrom(class: $this->class)->newInstance(constructorArguments: [$value]); } } diff --git a/src/Internal/Mappers/Object/Casters/EnumCaster.php b/src/Internal/Mappers/Object/Casters/EnumCaster.php index 05c8606..aa55627 100644 --- a/src/Internal/Mappers/Object/Casters/EnumCaster.php +++ b/src/Internal/Mappers/Object/Casters/EnumCaster.php @@ -5,23 +5,28 @@ namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use ReflectionEnum; +use ReflectionEnumBackedCase; use TinyBlocks\Mapper\Internal\Exceptions\InvalidCast; use UnitEnum; final readonly class EnumCaster implements Caster { - public function __construct(public string $class) + public function __construct(private string $class) { } public function castValue(mixed $value): UnitEnum { - $reflectionEnum = new ReflectionEnum($this->class); + if ($value instanceof $this->class) { + return $value; + } + + $reflection = new ReflectionEnum(objectOrClass: $this->class); - foreach ($reflectionEnum->getCases() as $case) { + foreach ($reflection->getCases() as $case) { $caseInstance = $case->getValue(); - if ($case->getEnum()->isBacked() && $case->getBackingValue() === $value) { + if ($case instanceof ReflectionEnumBackedCase && $case->getBackingValue() === $value) { return $caseInstance; } diff --git a/src/Internal/Mappers/Object/Casters/GeneratorCaster.php b/src/Internal/Mappers/Object/Casters/GeneratorCaster.php index 6d0b7e2..4f1af87 100644 --- a/src/Internal/Mappers/Object/Casters/GeneratorCaster.php +++ b/src/Internal/Mappers/Object/Casters/GeneratorCaster.php @@ -10,7 +10,7 @@ { public function castValue(mixed $value): Generator { - if (is_iterable($value)) { + if (is_iterable(value: $value)) { foreach ($value as $item) { yield $item; } diff --git a/src/Internal/Mappers/Object/Casters/Reflector.php b/src/Internal/Mappers/Object/Casters/Reflector.php new file mode 100644 index 0000000..813fa60 --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/Reflector.php @@ -0,0 +1,24 @@ +reflectionClass->newInstanceArgs($constructorArguments); + } +} diff --git a/src/Internal/Mappers/Object/ObjectMapper.php b/src/Internal/Mappers/Object/ObjectMapper.php deleted file mode 100644 index 048b8d1..0000000 --- a/src/Internal/Mappers/Object/ObjectMapper.php +++ /dev/null @@ -1,36 +0,0 @@ -getParameters(); - $inputProperties = iterator_to_array($iterable); - $constructorArguments = []; - - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - $value = $inputProperties[$name] ?? null; - - if ($value !== null) { - $caster = new CasterHandler(parameter: $parameter); - $castedValue = $caster->castValue(value: $value); - - $constructorArguments[] = $castedValue; - continue; - } - - $constructorArguments[] = $parameter->getDefaultValue(); - } - - return $reflectionClass->newInstance(constructorArguments: $constructorArguments); - } -} diff --git a/src/Internal/Mappers/Object/Reflector.php b/src/Internal/Mappers/Object/Reflector.php deleted file mode 100644 index a2a9435..0000000 --- a/src/Internal/Mappers/Object/Reflector.php +++ /dev/null @@ -1,49 +0,0 @@ -constructor = $reflectionClass->getConstructor(); - $this->parameters = $this->constructor ? $this->constructor->getParameters() : []; - } - - public static function reflectFrom(string $class): Reflector - { - return new Reflector(reflectionClass: new ReflectionClass($class)); - } - - public function getParameters(): array - { - return $this->parameters; - } - - public function newInstance(array $constructorArguments): object - { - $instance = $this->constructor && $this->constructor->isPrivate() - ? $this->newInstanceWithoutConstructor() - : $this->reflectionClass->newInstanceArgs($constructorArguments); - - if ($this->constructor && $this->constructor->isPrivate()) { - $this->constructor->invokeArgs($instance, $constructorArguments); - } - - return $instance; - } - - public function newInstanceWithoutConstructor(): object - { - return $this->reflectionClass->newInstanceWithoutConstructor(); - } -} diff --git a/src/Internal/Resolvers/RecursiveValueResolver.php b/src/Internal/Resolvers/RecursiveValueResolver.php new file mode 100644 index 0000000..f6b2b38 --- /dev/null +++ b/src/Internal/Resolvers/RecursiveValueResolver.php @@ -0,0 +1,49 @@ +valueObjectDetector->matches(value: $value)) { + $value = $this->unwrapper->transform(value: $value); + } + + if ($value instanceof Traversable) { + $value = iterator_to_array($value); + } + + if (is_array($value)) { + $mapped = array_map( + fn(mixed $item): mixed => $this->resolve(value: $item, keyPreservation: $keyPreservation), + $value + ); + + return $keyPreservation->shouldPreserveKeys() ? $mapped : array_values($mapped); + } + + if (is_object($value)) { + return $this->resolverContainer + ->get() + ->resolve(value: $value) + ->map(value: $value, keyPreservation: $keyPreservation); + } + + return $value; + } +} diff --git a/src/Internal/Resolvers/StrategyResolver.php b/src/Internal/Resolvers/StrategyResolver.php new file mode 100644 index 0000000..0218c06 --- /dev/null +++ b/src/Internal/Resolvers/StrategyResolver.php @@ -0,0 +1,29 @@ +strategies = $strategies; + } + + public function resolve(mixed $value): MappingStrategy + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports(value: $value)) { + return $strategy; + } + } + + return $this->default; + } +} diff --git a/src/Internal/Resolvers/StrategyResolverContainer.php b/src/Internal/Resolvers/StrategyResolverContainer.php new file mode 100644 index 0000000..ddde1de --- /dev/null +++ b/src/Internal/Resolvers/StrategyResolverContainer.php @@ -0,0 +1,20 @@ +resolver = $resolver; + } + + public function get(): StrategyResolver + { + return $this->resolver; + } +} diff --git a/src/Internal/Strategies/ComplexObjectMappingStrategy.php b/src/Internal/Strategies/ComplexObjectMappingStrategy.php new file mode 100644 index 0000000..9b448ab --- /dev/null +++ b/src/Internal/Strategies/ComplexObjectMappingStrategy.php @@ -0,0 +1,33 @@ +extractor->extractProperties(object: $value); + + $mapped = array_map( + fn(mixed $propertyValue): mixed => $this->resolver->resolve( + value: $propertyValue, + keyPreservation: $keyPreservation + ), + $properties + ); + + return $keyPreservation->shouldPreserveKeys() + ? $mapped + : array_values($mapped); + } +} diff --git a/src/Internal/Strategies/ConditionalMappingStrategy.php b/src/Internal/Strategies/ConditionalMappingStrategy.php new file mode 100644 index 0000000..989dafd --- /dev/null +++ b/src/Internal/Strategies/ConditionalMappingStrategy.php @@ -0,0 +1,19 @@ +transformer->transform(value: $value); + } + + public function supports(mixed $value): bool + { + return $this->detector->matches(value: $value); + } +} diff --git a/src/Internal/Strategies/EnumMappingStrategy.php b/src/Internal/Strategies/EnumMappingStrategy.php new file mode 100644 index 0000000..d64ffea --- /dev/null +++ b/src/Internal/Strategies/EnumMappingStrategy.php @@ -0,0 +1,26 @@ +transformer->transform(value: $value); + } + + public function supports(mixed $value): bool + { + return $this->detector->matches(value: $value); + } +} diff --git a/src/Internal/Strategies/IterableMappingStrategy.php b/src/Internal/Strategies/IterableMappingStrategy.php new file mode 100644 index 0000000..19a69c6 --- /dev/null +++ b/src/Internal/Strategies/IterableMappingStrategy.php @@ -0,0 +1,34 @@ + $this->resolver->resolve(value: $item, keyPreservation: $keyPreservation), + $this->extractor->extract(object: $value) + ); + + return $keyPreservation->shouldPreserveKeys() + ? $mapped + : array_values($mapped); + } + + public function supports(mixed $value): bool + { + return $value instanceof IterableMapper; + } +} diff --git a/src/Internal/Strategies/MappingStrategy.php b/src/Internal/Strategies/MappingStrategy.php new file mode 100644 index 0000000..45bff52 --- /dev/null +++ b/src/Internal/Strategies/MappingStrategy.php @@ -0,0 +1,22 @@ +format(format: self::ISO8601_FORMAT); + } +} diff --git a/src/Internal/Transformers/EnumTransformer.php b/src/Internal/Transformers/EnumTransformer.php new file mode 100644 index 0000000..7636437 --- /dev/null +++ b/src/Internal/Transformers/EnumTransformer.php @@ -0,0 +1,19 @@ +value; + } + + return $value->name; + } +} diff --git a/src/Internal/Transformers/Transformer.php b/src/Internal/Transformers/Transformer.php new file mode 100644 index 0000000..470b70a --- /dev/null +++ b/src/Internal/Transformers/Transformer.php @@ -0,0 +1,19 @@ +valueObjectDetector->matches(value: $current)) { + $current = $this->extractor->extract(object: $current); + } + + return $current; + } +} diff --git a/src/IterableMappability.php b/src/IterableMappability.php index cfbe7e9..06cc31f 100644 --- a/src/IterableMappability.php +++ b/src/IterableMappability.php @@ -4,24 +4,11 @@ namespace TinyBlocks\Mapper; -use TinyBlocks\Mapper\Internal\Mappers\Collection\ArrayMapper; -use TinyBlocks\Mapper\Internal\Mappers\Json\JsonMapper; +use TinyBlocks\Mapper\Internal\MappabilityBehavior; trait IterableMappability { - public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string - { - $mapper = new JsonMapper(); - - return $mapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); - } - - public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array - { - $mapper = new ArrayMapper(); - - return $mapper->map(value: $this, keyPreservation: $keyPreservation); - } + use MappabilityBehavior; public function getType(): string { diff --git a/src/KeyPreservation.php b/src/KeyPreservation.php index 6a05b1b..494f247 100644 --- a/src/KeyPreservation.php +++ b/src/KeyPreservation.php @@ -26,6 +26,9 @@ enum KeyPreservation */ public function shouldPreserveKeys(): bool { - return $this === self::PRESERVE; + return match ($this) { + self::PRESERVE => true, + self::DISCARD => false + }; } } diff --git a/src/ObjectMappability.php b/src/ObjectMappability.php index b8369a0..ce50b99 100644 --- a/src/ObjectMappability.php +++ b/src/ObjectMappability.php @@ -4,30 +4,18 @@ namespace TinyBlocks\Mapper; -use TinyBlocks\Mapper\Internal\Mappers\Collection\ArrayMapper; -use TinyBlocks\Mapper\Internal\Mappers\Json\JsonMapper; -use TinyBlocks\Mapper\Internal\Mappers\Object\ObjectMapper; +use TinyBlocks\Mapper\Internal\Builders\ObjectBuilder; +use TinyBlocks\Mapper\Internal\Extractors\ReflectionExtractor; +use TinyBlocks\Mapper\Internal\MappabilityBehavior; trait ObjectMappability { - public static function fromIterable(iterable $iterable): static - { - $mapper = new ObjectMapper(); + use MappabilityBehavior; - return $mapper->map(iterable: $iterable, class: static::class); - } - - public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string - { - $mapper = new JsonMapper(); - - return $mapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); - } - - public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array + public static function fromIterable(iterable $iterable): static { - $mapper = new ArrayMapper(); + $extractor = new ReflectionExtractor(); - return $mapper->map(value: $this, keyPreservation: $keyPreservation); + return new ObjectBuilder(extractor: $extractor)->build(iterable: $iterable, class: static::class); } } diff --git a/tests/CollectionMappingTest.php b/tests/CollectionMappingTest.php new file mode 100644 index 0000000..d081251 --- /dev/null +++ b/tests/CollectionMappingTest.php @@ -0,0 +1,327 @@ +toArray(); + + /** @Then the mapped array should be empty */ + self::assertSame([], $actual); + + /** @And the JSON representation should be an empty JSON array */ + self::assertJsonStringEqualsJsonString('[]', $employees->toJson()); + + /** @And the Employees collection should have expected type and count */ + self::assertSame(0, $employees->count()); + self::assertSame(Employee::class, $employees->getType()); + } + + #[DataProvider('collectionOfObjectsProvider')] + public function testCollectionOfObjects(string $type, IterableMapper $collection, array $expected): void + { + /** @Given a Collection of objects */ + /** @When mapping the collection to an array */ + $actual = $collection->toArray(); + + /** @Then the mapped array should have expected values */ + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $collection->toJson()); + + /** @And the Collection should have expected type */ + self::assertSame($type, $collection->getType()); + } + + public function testCollectionOfScalars(): void + { + /** @Given an Attributes collection with integer values */ + $attributes = Attributes::createFrom(elements: [PHP_INT_MAX, 'red', 3.14, true, false, null, ['id' => 1]]); + + /** @When mapping the Attributes collection to an array */ + $actual = $attributes->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [PHP_INT_MAX, 'red', 3.14, true, false, null, ['id' => 1]]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $attributes->toJson()); + + /** @And the Numbers collection should have expected type and count */ + self::assertSame(7, $attributes->count()); + self::assertSame('mixed', $attributes->getType()); + } + + #[DataProvider('collectionDiscardKeysProvider')] + public function testCollectionDiscardKeys(IterableMapper $collection, array $expected): void + { + /** @Given a Collection with values having keys */ + /** @When mapping the Collection to an array */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the mapped array should have expected values */ + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString( + (string)json_encode($expected), + $collection->toJson(keyPreservation: KeyPreservation::DISCARD) + ); + } + + public function testCollectionGetTypeReturnsOwnClass(): void + { + /** @Given a Collection with arrays */ + $collection = Collection::createFrom(elements: [['id' => 1], ['id' => 2]]); + + /** @When mapping the Collection to an array */ + $actual = $collection->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [['id' => 1], ['id' => 2]]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $collection->toJson()); + } + + public function testCollectionWithNoConstructorElements(): void + { + /** @Given a Tags collection with a default Tag object */ + $tags = Tags::createFrom(elements: [new Tag()]); + + /** @When mapping the Tags collection to an array */ + $actual = $tags->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [['name' => '', 'color' => 'gray']]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $tags->toJson()); + + /** @And the Tags collection should have expected type and count */ + self::assertSame(1, $tags->count()); + self::assertSame(Tag::class, $tags->getType()); + } + + public static function collectionOfObjectsProvider(): iterable + { + return [ + 'Members collection' => [ + 'type' => Member::class, + 'collection' => Members::createFrom(elements: [ + new Member( + id: new MemberId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')), + role: 'owner', + isOwner: true, + organizationId: new OrganizationId( + value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23') + ) + ), + new Member( + id: new MemberId(value: new Uuid(value: 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6')), + role: 'admin', + isOwner: false, + organizationId: new OrganizationId( + value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23') + ) + ) + ]), + 'expected' => [ + [ + 'id' => '88f15d3f-c9b9-4855-9778-5ba7926b6736', + 'role' => 'owner', + 'isOwner' => true, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ], + [ + 'id' => 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6', + 'role' => 'admin', + 'isOwner' => false, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ] + ] + ], + 'Articles collection' => [ + 'type' => Articles::class, + 'collection' => Articles::createFrom(elements: [ + ['id' => 1, 'title' => 'First Article'], + ['id' => 2, 'title' => 'Second Article'] + ]), + 'expected' => [ + ['id' => 1, 'title' => 'First Article'], + ['id' => 2, 'title' => 'Second Article'] + ] + ], + 'Products collection' => [ + 'type' => Product::class, + 'collection' => new Products( + items: [ + new Product( + id: 1, + amount: Amount::from(value: 99.99, currency: Currency::USD), + description: new Description(text: 'A high-quality product'), + attributes: new ArrayIterator([ + 'color' => 'red', + 'size' => 'M', + 'inStock' => true + ]), + inventory: ['stock' => 100, 'warehouse' => 'A1'], + status: ProductStatus::ACTIVE, + createdAt: new DateTimeImmutable('2026-01-01T10:00:00+00:00') + ), + new Product( + id: 2, + amount: Amount::from(value: 149.99, currency: Currency::USD), + description: new Description(text: 'A premium product'), + attributes: new ArrayIterator([ + 'color' => 'blue', + 'size' => 'L', + 'inStock' => false + ]), + inventory: ['stock' => 0, 'warehouse' => 'B2'], + status: ProductStatus::INACTIVE, + createdAt: new DateTimeImmutable('2026-01-01T10:00:00+00:00') + ) + ], + country: Country::UNITED_STATES + ), + 'expected' => [ + [ + 'id' => 1, + 'amount' => [ + 'value' => 99.99, + 'currency' => 'USD' + ], + 'description' => 'A high-quality product', + 'attributes' => [ + 'color' => 'red', + 'size' => 'M', + 'inStock' => true + ], + 'inventory' => ['stock' => 100, 'warehouse' => 'A1'], + 'status' => 1, + 'createdAt' => '2026-01-01T10:00:00+00:00' + ], + [ + 'id' => 2, + 'amount' => [ + 'value' => 149.99, + 'currency' => 'USD' + ], + 'description' => 'A premium product', + 'attributes' => [ + 'color' => 'blue', + 'size' => 'L', + 'inStock' => false + ], + 'inventory' => ['stock' => 0, 'warehouse' => 'B2'], + 'status' => 2, + 'createdAt' => '2026-01-01T10:00:00+00:00' + ] + ] + ], + 'Employees collection' => [ + 'type' => Employee::class, + 'collection' => Employees::createFrom(elements: [ + new Employee(name: 'Anne'), + new Employee(name: 'Gustavo', department: 'Technology'), + new Employee(name: 'John', department: 'Marketing', active: false) + ]), + 'expected' => [ + ['name' => 'Anne', 'department' => 'general', 'active' => true], + ['name' => 'Gustavo', 'department' => 'Technology', 'active' => true], + ['name' => 'John', 'department' => 'Marketing', 'active' => false] + ] + ] + ]; + } + + public static function collectionDiscardKeysProvider(): iterable + { + return [ + 'Collection with string keys' => [ + 'collection' => Collection::createFrom(elements: [ + ['id' => 1, 'name' => 'Gustavo'], + ['id' => 2, 'name' => 'Anne'] + ]), + 'expected' => [ + [1, 'Gustavo'], + [2, 'Anne'] + ] + ], + 'Collection with integer keys' => [ + 'collection' => Collection::createFrom(elements: [ + 10 => 'first', + 20 => 'second', + 30 => 'third' + ]), + 'expected' => [ + 'first', + 'second', + 'third' + ] + ], + 'Collection of objects with string keys' => [ + 'collection' => Collection::createFrom(elements: [ + 'gustavo' => new MemberId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')), + 'anne' => new MemberId(value: new Uuid(value: 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6')) + ]), + 'expected' => [ + '88f15d3f-c9b9-4855-9778-5ba7926b6736', + 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6' + ] + ], + 'Collection of objects with integer keys' => [ + 'collection' => Collection::createFrom(elements: [ + 100 => new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736'), + 200 => new Uuid(value: 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6') + ]), + 'expected' => [ + '88f15d3f-c9b9-4855-9778-5ba7926b6736', + 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6' + ] + ] + ]; + } +} diff --git a/tests/EnumMappingTest.php b/tests/EnumMappingTest.php new file mode 100644 index 0000000..b92c4e9 --- /dev/null +++ b/tests/EnumMappingTest.php @@ -0,0 +1,113 @@ + 'Smaug', + 'type' => 'FIRE', + 'power' => 9999.99, + 'skills' => [] + ]); + + /** @When mapping the Dragon to an array */ + $actual = $dragon->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'name' => 'Smaug', + 'type' => 'FIRE', + 'power' => 9999.99, + 'skills' => [] + ]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); + } + + public function testEnumBackedInt(): void + { + /** @Given a Task with an array of backed int enums */ + $task = Task::fromIterable(iterable: [ + 'title' => 'Fix bug', + 'priority' => 3 + ]); + + /** @When mapping the Task to an array */ + $actual = $task->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'title' => 'Fix bug', + 'priority' => 3 + ]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $task->toJson()); + } + + public function testEnumBackedString(): void + { + /** @Given a Dragon with an array of backed string enums */ + $dragon = new Dragon( + name: 'Alduin', + type: DragonType::FIRE, + power: 10000.00, + skills: [ + DragonSkills::FLY, + DragonSkills::ELEMENTAL_BREATH, + DragonSkills::REGENERATION + ] + ); + + /** @When mapping the Dragon to an array */ + $actual = $dragon->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'name' => 'Alduin', + 'type' => 'FIRE', + 'power' => 10000.00, + 'skills' => ['fly', 'elemental_breath', 'regeneration'] + ]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); + } + + public function testEnumWhenInvalidCast(): void + { + /** @Given data with an invalid currency value */ + $data = [ + 'value' => 250.00, + 'currency' => 'INVALID' + ]; + + /** @Then an exception should be thrown */ + $this->expectException(InvalidCast::class); + $this->expectExceptionMessage('Invalid value for enum .'); + + /** @When creating from iterable */ + Amount::fromIterable(iterable: $data); + } +} diff --git a/tests/IterableMapperTest.php b/tests/IterableMapperTest.php deleted file mode 100644 index 1c379a2..0000000 --- a/tests/IterableMapperTest.php +++ /dev/null @@ -1,495 +0,0 @@ -toJson(); - - /** @Then the result should match the expected */ - self::assertJsonStringEqualsJsonString($expected, $actual); - } - - #[DataProvider('dataProviderForToArray')] - public function testCollectionToArray(iterable $elements, iterable $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When converting the collection to array */ - $actual = $collection->toArray(); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual); - self::assertSame(count($expected), $collection->count()); - } - - #[DataProvider('dataProviderForToJsonDiscardKeys')] - public function testCollectionToJsonDiscardKeys(iterable $elements, string $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When converting the collection to JSON while discarding keys */ - $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); - - /** @Then the result should match the expected */ - self::assertJsonStringEqualsJsonString($expected, $actual); - } - - #[DataProvider('dataProviderForToJsonPreserveKeys')] - public function testCollectionToJsonPreserveKeys(iterable $elements, string $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When converting the collection to JSON while preserve keys */ - $actual = $collection->toJson(); - - /** @Then the result should match the expected */ - self::assertJsonStringEqualsJsonString($expected, $actual); - } - - #[DataProvider('dataProviderForToArrayDiscardKeys')] - public function testCollectionToArrayDiscardKeys(iterable $elements, iterable $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When converting the collection to array while discarding keys */ - $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual); - self::assertSame(count($expected), $collection->count()); - } - - #[DataProvider('dataProviderForToArrayPreserveKeys')] - public function testCollectionToArrayPreserveKeys(iterable $elements, iterable $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When converting the collection to array while preserve keys */ - $actual = $collection->toArray(); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual); - self::assertSame(count($expected), $collection->count()); - } - - public function testInvalidCollectionValueToArrayReturnsEmptyArray(): void - { - /** @Given a collection with an invalid item (e.g., a function that cannot be serialized) */ - $collection = Collection::createFrom(elements: [fn(): null => null, fn(): null => null]); - - /** @When attempting to serialize the collection containing the invalid items */ - $actual = $collection->toJson(); - - /** @Then the invalid item should be serialized as an empty array in the JSON output */ - self::assertSame('[]', $actual); - } - - public static function dataProviderForToJson(): iterable - { - return [ - 'Empty collection' => [ - 'elements' => [], - 'expected' => '[]' - ], - 'Order collection' => [ - 'elements' => [ - new Order( - id: '2c485713-521c-4d91-b9e7-1294f132ad2e', - items: (static function () { - yield ['name' => 'Macbook Pro']; - yield ['name' => 'iPhone XYZ']; - })(), - createdAt: DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s', - '2000-01-01 00:00:00', - new DateTimeZone('America/Sao_Paulo') - ) - ) - ], - 'expected' => '[{"id":"2c485713-521c-4d91-b9e7-1294f132ad2e","items":[{"name":"Macbook Pro"},{"name":"iPhone XYZ"}],"createdAt":"2000-01-01T00:00:00-02:00"}]' - ], - 'Scalar collection' => [ - 'elements' => ['iPhone', PHP_INT_MAX, 123.456, ['nested' => PHP_INT_MAX], PHP_INT_MIN], - 'expected' => '["iPhone", 9223372036854775807, 123.456, {"nested":9223372036854775807}, -9223372036854775808]' - ], - 'Dragon collection' => [ - 'elements' => [ - new Dragon( - name: 'Ignithar Blazeheart', - type: DragonType::FIRE, - power: 10000000.00, - skills: DragonSkills::cases() - ) - ], - 'expected' => '[{"name":"Ignithar Blazeheart","type":"FIRE","power":10000000,"skills":["fly","spell","regeneration","elemental_breath"]}]' - ], - 'Decimal collection' => [ - 'elements' => [ - new Decimal(value: 100.00), - new Decimal(value: 123.45), - new Decimal(value: 999.99), - ], - 'expected' => '[{"value":100},{"value":123.45},{"value":999.99}]' - ], - 'Product collection' => [ - 'elements' => [ - new Product( - name: 'Macbook Pro', - amount: Amount::from(value: 1600.00, currency: Currency::USD), - stockBatch: new ArrayIterator([1000, 2000, 3000]) - ) - ], - 'expected' => '[{"name":"Macbook Pro","amount":{"value":1600,"currency":"USD"},"stockBatch":[1000,2000,3000]}]' - ], - 'Expiration date collection' => [ - 'elements' => [ - new ExpirationDate( - value: new DateTimeImmutable( - '2000-01-01 00:00:00', - new DateTimeZone('UTC') - ) - ) - ], - 'expected' => '[{"value": "2000-01-01 00:00:00"}]' - ], - 'Shipping object with no addresses' => [ - 'elements' => [ - new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()) - ], - 'expected' => '[{"id":9223372036854775807,"addresses":[]}]' - ], - 'Shipping object with a single address' => [ - 'elements' => [ - new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ) - ], - 'expected' => '[{"id":-9223372036854775808,"addresses":[{"city":"São Paulo","state":"SP","street":"Avenida Paulista","number":100,"country":"BR"}]}]' - ], - 'Shipping object with multiple addresses' => [ - 'elements' => [ - new Shipping( - id: 100000, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: '5th Avenue', - number: 1, - country: ShippingCountry::UNITED_STATES - ), - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: 'Broadway', - number: 42, - country: ShippingCountry::UNITED_STATES - ) - ] - ) - ) - ], - 'expected' => '[{"id":100000,"addresses":[{"city":"New York","state":"NY","street":"5th Avenue","number":1,"country":"US"},{"city":"New York","state":"NY","street":"Broadway","number":42,"country":"US"}]}]' - ] - ]; - } - - public static function dataProviderForToArray(): iterable - { - return [ - 'Order collection' => [ - 'elements' => [ - new Order( - id: '2c485713-521c-4d91-b9e7-1294f132ad2e', - items: (static function () { - yield ['name' => 'Macbook Pro']; - yield ['name' => 'iPhone XYZ']; - })(), - createdAt: DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s', - '2000-01-01 00:00:00', - new DateTimeZone('America/Sao_Paulo') - ) - ) - ], - 'expected' => [ - [ - 'id' => '2c485713-521c-4d91-b9e7-1294f132ad2e', - 'items' => [['name' => 'Macbook Pro'], ['name' => 'iPhone XYZ']], - 'createdAt' => '2000-01-01T00:00:00-02:00' - ] - ] - ], - 'Dragon collection' => [ - 'elements' => [ - new Dragon( - name: 'Ignithar Blazeheart', - type: DragonType::FIRE, - power: 10000000.00, - skills: DragonSkills::cases() - ) - ], - 'expected' => [ - [ - 'name' => 'Ignithar Blazeheart', - 'type' => 'FIRE', - 'power' => 10000000.00, - 'skills' => ['fly', 'spell', 'regeneration', 'elemental_breath'] - ] - ] - ], - 'Decimal collection' => [ - 'elements' => [ - new Decimal(value: 100.00), - new Decimal(value: 123.45), - new Decimal(value: 999.99), - ], - 'expected' => [ - ['value' => 100.00], - ['value' => 123.45], - ['value' => 999.99] - ] - ], - 'Product collection' => [ - 'elements' => [ - new Product( - name: 'Macbook Pro', - amount: Amount::from(value: 1600.00, currency: Currency::USD), - stockBatch: new ArrayIterator([1000, 2000, 3000]) - ) - ], - 'expected' => [ - [ - 'name' => 'Macbook Pro', - 'amount' => ['value' => 1600.00, 'currency' => Currency::USD->value], - 'stockBatch' => [1000, 2000, 3000] - ] - ] - ], - 'Expiration date collection' => [ - 'elements' => [ - new ExpirationDate( - value: new DateTimeImmutable( - '2000-01-01 00:00:00', - new DateTimeZone('UTC') - ) - ) - ], - 'expected' => [['value' => '2000-01-01 00:00:00']] - ], - 'Shipping object with no addresses' => [ - 'elements' => [ - new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()) - ], - 'expected' => [ - [ - 'id' => PHP_INT_MAX, - 'addresses' => [] - ] - ] - ], - 'Shipping object with a single address' => [ - 'elements' => [ - new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ) - ], - 'expected' => [ - [ - 'id' => PHP_INT_MIN, - 'addresses' => [ - [ - 'city' => 'São Paulo', - 'state' => ShippingState::SP->name, - 'street' => 'Avenida Paulista', - 'number' => 100, - 'country' => ShippingCountry::BRAZIL->value - ] - ] - ] - ] - ], - 'Shipping object with multiple addresses' => [ - 'elements' => [ - new Shipping( - id: 100000, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: '5th Avenue', - number: 1, - country: ShippingCountry::UNITED_STATES - ), - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: 'Broadway', - number: 42, - country: ShippingCountry::UNITED_STATES - ) - ] - ) - ) - ], - 'expected' => [ - [ - 'id' => 100000, - 'addresses' => [ - [ - 'city' => 'New York', - 'state' => ShippingState::NY->name, - 'street' => '5th Avenue', - 'number' => 1, - 'country' => ShippingCountry::UNITED_STATES->value - ], - [ - 'city' => 'New York', - 'state' => ShippingState::NY->name, - 'street' => 'Broadway', - 'number' => 42, - 'country' => ShippingCountry::UNITED_STATES->value - ] - ] - ] - ] - ] - ]; - } - - public static function dataProviderForToJsonDiscardKeys(): iterable - { - return [ - 'Scalar collection' => [ - 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], - 'expected' => '[12.34,"apple",100,true]' - ], - 'ArrayIterator collection' => [ - 'elements' => new ArrayIterator([ - 'float' => 12.34, - 'string' => 'apple', - 'integer' => 100, - 'boolean' => true - ]), - 'expected' => '[12.34,"apple",100,true]' - ] - ]; - } - - public static function dataProviderForToJsonPreserveKeys(): iterable - { - return [ - 'Scalar collection' => [ - 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], - 'expected' => '{"float":12.34,"string":"apple","integer":100,"boolean":true}' - ], - 'ArrayIterator collection' => [ - 'elements' => new ArrayIterator([ - 'float' => 12.34, - 'string' => 'apple', - 'integer' => 100, - 'boolean' => true - ]), - 'expected' => '{"float":12.34,"string":"apple","integer":100,"boolean":true}' - ] - ]; - } - - public static function dataProviderForToArrayDiscardKeys(): iterable - { - return [ - 'Scalar collection' => [ - 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], - 'expected' => [12.34, 'apple', 100, true] - ], - 'ArrayIterator collection' => [ - 'elements' => new ArrayIterator([ - 'float' => 12.34, - 'string' => 'apple', - 'integer' => 100, - 'boolean' => true - ]), - 'expected' => [12.34, 'apple', 100, true] - ] - ]; - } - - public static function dataProviderForToArrayPreserveKeys(): iterable - { - return [ - 'Scalar collection' => [ - 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], - 'expected' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true] - ], - 'ArrayIterator collection' => [ - 'elements' => new ArrayIterator([ - 'float' => 12.34, - 'string' => 'apple', - 'integer' => 100, - 'boolean' => true - ]), - 'expected' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true] - ] - ]; - } -} diff --git a/tests/Models/Amount.php b/tests/Models/Amount.php index 727f284..f959b79 100644 --- a/tests/Models/Amount.php +++ b/tests/Models/Amount.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Mapper\Models; +namespace Test\TinyBlocks\Mapper\Models; use TinyBlocks\Mapper\ObjectMappability; use TinyBlocks\Mapper\ObjectMapper; diff --git a/tests/Models/Articles.php b/tests/Models/Articles.php new file mode 100644 index 0000000..c5f4c46 --- /dev/null +++ b/tests/Models/Articles.php @@ -0,0 +1,9 @@ +elements; + } +} diff --git a/tests/Models/Catalog.php b/tests/Models/Catalog.php new file mode 100644 index 0000000..43b2f0c --- /dev/null +++ b/tests/Models/Catalog.php @@ -0,0 +1,17 @@ +iterator = $iterator; - } - - public static function createFrom(iterable $elements): Collectible - { - return new Collection(iterator: $elements); - } - - public static function createFromEmpty(): Collectible - { - // TODO: Implement createFromEmpty() method. - } - - public function add(...$elements): Collectible + private function __construct(public readonly iterable $elements) { - // TODO: Implement add() method. } - public function contains(mixed $element): bool + public static function createFrom(iterable $elements): static { - // TODO: Implement contains() method. + return new static(elements: $elements); } public function count(): int { - return iterator_count($this->iterator); - } + if (is_array($this->elements)) { + return count($this->elements); + } - public function each(Closure ...$actions): Collectible - { - // TODO: Implement each() method. - } + $count = 0; - public function equals(Collectible $other): bool - { - // TODO: Implement equals() method. - } + foreach ($this->elements as $ignored) { + $count++; + } - public function filter(?Closure ...$predicates): Collectible - { - // TODO: Implement filter() method. - } - - public function findBy(Closure ...$predicates): mixed - { - // TODO: Implement findBy() method. - } - - public function first(mixed $defaultValueIfNotFound = null): mixed - { - // TODO: Implement first() method. - } - - public function flatten(): Collectible - { - // TODO: Implement flatten() method. - } - - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed - { - // TODO: Implement getBy() method. - } - - public function getIterator(): Traversable - { - yield from $this->iterator; - } - - public function groupBy(Closure $grouping): Collectible - { - // TODO: Implement groupBy() method. - } - - public function isEmpty(): bool - { - // TODO: Implement isEmpty() method. - } - - public function joinToString(string $separator): string - { - // TODO: Implement joinToString() method. - } - - public function last(mixed $defaultValueIfNotFound = null): mixed - { - // TODO: Implement last() method. - } - - public function map(Closure ...$transformations): Collectible - { - // TODO: Implement map() method. - } - - public function remove(mixed $element): Collectible - { - // TODO: Implement remove() method. - } - - public function removeAll(?Closure $filter = null): Collectible - { - // TODO: Implement removeAll() method. - } - - public function reduce(Closure $aggregator, mixed $initial): mixed - { - // TODO: Implement reduce() method. - } - - public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible - { - // TODO: Implement sort() method. - } - - public function slice(int $index, int $length = -1): Collectible - { - // TODO: Implement slice() method. + return $count; } } diff --git a/tests/Models/Color.php b/tests/Models/Color.php new file mode 100644 index 0000000..6d24bbd --- /dev/null +++ b/tests/Models/Color.php @@ -0,0 +1,12 @@ + $this->value]; - } -} diff --git a/tests/Models/Description.php b/tests/Models/Description.php new file mode 100644 index 0000000..8deffa0 --- /dev/null +++ b/tests/Models/Description.php @@ -0,0 +1,12 @@ +iterator = $iterator; - } - - public static function createFrom(iterable $elements): Stores - { - return new Stores(iterator: $elements); - } - - public static function createFromEmpty(): Collectible - { - // TODO: Implement createFromEmpty() method. - } - - public function add(...$elements): Collectible - { - // TODO: Implement add() method. - } - - public function contains(mixed $element): bool - { - // TODO: Implement contains() method. - } - - public function count(): int - { - return iterator_count($this->iterator); - } - - public function each(Closure ...$actions): Collectible - { - // TODO: Implement each() method. - } - - public function equals(Collectible $other): bool - { - // TODO: Implement equals() method. - } - - public function filter(?Closure ...$predicates): Collectible - { - // TODO: Implement filter() method. - } - - public function findBy(Closure ...$predicates): mixed - { - // TODO: Implement findBy() method. - } - - public function first(mixed $defaultValueIfNotFound = null): mixed - { - // TODO: Implement first() method. - } - - public function flatten(): Collectible - { - // TODO: Implement flatten() method. - } - - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed - { - // TODO: Implement getBy() method. - } - - public function getIterator(): Traversable - { - yield from $this->iterator; - } - - public function groupBy(Closure $grouping): Collectible - { - // TODO: Implement groupBy() method. - } - - public function isEmpty(): bool - { - // TODO: Implement isEmpty() method. - } - - public function joinToString(string $separator): string - { - // TODO: Implement joinToString() method. - } - - public function last(mixed $defaultValueIfNotFound = null): mixed - { - // TODO: Implement last() method. - } - - public function map(Closure ...$transformations): Collectible - { - // TODO: Implement map() method. - } - - public function remove(mixed $element): Collectible - { - // TODO: Implement remove() method. - } - - public function removeAll(?Closure $filter = null): Collectible - { - // TODO: Implement removeAll() method. - } - - public function reduce(Closure $aggregator, mixed $initial): mixed - { - // TODO: Implement reduce() method. - } - - public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible - { - // TODO: Implement sort() method. - } - - public function slice(int $index, int $length = -1): Collectible - { - // TODO: Implement slice() method. - } -} diff --git a/tests/Models/Tag.php b/tests/Models/Tag.php new file mode 100644 index 0000000..9db3466 --- /dev/null +++ b/tests/Models/Tag.php @@ -0,0 +1,16 @@ +toJson(); - - /** @Then the result should match the expected */ - self::assertJsonStringEqualsJsonString($expected, $actual); - } - - #[DataProvider('dataProviderForToArray')] - public function testObjectToArray(ObjectMapper $object, iterable $expected): void - { - /** @Given an object with values */ - /** @When converting the object to array */ - $actual = $object->toArray(); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual); - } - - #[DataProvider('dataProviderForToJsonDiscardKeys')] - public function testObjectToJsonDiscardKeys(ObjectMapper $object, string $expected): void - { - /** @Given an object with values */ - /** @When converting the object to JSON while discarding keys */ - $actual = $object->toJson(keyPreservation: KeyPreservation::DISCARD); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual); - } - - #[DataProvider('dataProviderForToArrayDiscardKeys')] - public function testObjectToArrayDiscardKeys(ObjectMapper $object, iterable $expected): void - { - /** @Given an object with values */ - /** @When converting the object to array while discarding keys */ - $actual = $object->toArray(keyPreservation: KeyPreservation::DISCARD); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual); - } - - #[DataProvider('dataProviderForIterableToObject')] - public function testIterableToObject(iterable $iterable, ObjectMapper $expected): void - { - /** @Given an iterable with values */ - /** @When converting the array to object */ - $actual = $expected::fromIterable(iterable: $iterable); - - /** @Then the result should match the expected */ - self::assertSame($expected->toArray(), $actual->toArray()); - self::assertEquals($expected, $actual); - self::assertNotSame($expected, $actual); - } - - public function testIterableToObjectWithDefaultParameterValue(): void - { - /** @Given an iterable with values */ - $iterable = ['name' => 'Zephyrax the Tempest']; - - /** @When converting the iterable to an object */ - $actual = Customer::fromIterable(iterable: $iterable); - - /** @Then the result should have the default value for the missing parameter */ - self::assertSame('Zephyrax the Tempest', $actual->name); - self::assertSame(0, $actual->score); - self::assertNull($actual->gender); - } - - public function testInvalidObjectValueToArrayReturnsEmptyArray(): void - { - /** @Given an object with an invalid item (e.g., a function that cannot be serialized) */ - $service = new Service(action: fn(): int => 0); - - /** @When attempting to serialize the object containing the invalid item */ - $actual = $service->toJson(); - - /** @Then the invalid item should be serialized as an empty array in the JSON output */ - self::assertSame('[]', $actual); - } - - public function testExceptionWhenInvalidCast(): void - { - /** @Given an iterable with invalid values */ - $iterable = ['value' => 100.50, 'currency' => 'EUR']; - - /** @Then a InvalidCast exception should be thrown */ - self::expectException(InvalidCast::class); - self::expectExceptionMessage('Invalid value for enum .'); - - /** @When the fromIterable method is called on the object */ - Amount::fromIterable(iterable: $iterable); - } - - public static function dataProviderForToJson(): iterable - { - return [ - 'Order object' => [ - 'object' => new Order( - id: '2c485713-521c-4d91-b9e7-1294f132ad2e', - items: (static function () { - yield ['name' => 'Macbook Pro']; - yield ['name' => 'iPhone XYZ']; - })(), - createdAt: DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s', - '2000-01-01 00:00:00', - new DateTimeZone('America/Sao_Paulo') - ) - ), - 'expected' => '{"id":"2c485713-521c-4d91-b9e7-1294f132ad2e","items":[{"name":"Macbook Pro"},{"name":"iPhone XYZ"}],"createdAt":"2000-01-01T00:00:00-02:00"}' - ], - 'Dragon object' => [ - 'object' => new Dragon( - name: 'Ignithar Blazeheart', - type: DragonType::FIRE, - power: 10000000.00, - skills: DragonSkills::cases() - ), - 'expected' => '{"name":"Ignithar Blazeheart","type":"FIRE","power":10000000,"skills":["fly","spell","regeneration","elemental_breath"]}' - ], - 'Decimal object' => [ - 'object' => new Decimal(value: 999.99), - 'expected' => '{"value":999.99}' - ], - 'Product object' => [ - 'object' => new Product( - name: 'Macbook Pro', - amount: Amount::from(value: 1600.00, currency: Currency::USD), - stockBatch: new ArrayIterator([1000, 2000, 3000]) - ), - 'expected' => '{"name":"Macbook Pro","amount":{"value":1600,"currency":"USD"},"stockBatch":[1000,2000,3000]}' - ], - 'ExpirationDate object' => [ - 'object' => new ExpirationDate( - value: new DateTimeImmutable( - '2000-01-01 00:00:00', - new DateTimeZone('UTC') - ) - ), - 'expected' => '{"value":"2000-01-01 00:00:00"}' - ], - 'Shipping object with no addresses' => [ - 'object' => new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()), - 'expected' => '{"id": 9223372036854775807,"addresses":[]}' - ], - 'Shipping object with a single address' => [ - 'object' => new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ), - 'expected' => '{"id": -9223372036854775808,"addresses":[{"city":"São Paulo","state":"SP","street":"Avenida Paulista","number":100,"country":"BR"}]}' - ], - 'Shipping object with multiple addresses' => [ - 'object' => new Shipping( - id: 100000, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: '5th Avenue', - number: 1, - country: ShippingCountry::UNITED_STATES - ), - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: 'Broadway', - number: 42, - country: ShippingCountry::UNITED_STATES - ) - ] - ) - ), - 'expected' => '{"id":100000,"addresses":[{"city":"New York","state":"NY","street":"5th Avenue","number":1,"country":"US"},{"city":"New York","state":"NY","street":"Broadway","number":42,"country":"US"}]}' - ] - ]; - } - - public static function dataProviderForToArray(): iterable - { - return [ - 'Order object' => [ - 'object' => new Order( - id: '2c485713-521c-4d91-b9e7-1294f132ad2e', - items: (static function () { - yield ['name' => 'Macbook Pro']; - yield ['name' => 'iPhone XYZ']; - })(), - createdAt: DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s', - '2000-01-01 00:00:00', - new DateTimeZone('America/Sao_Paulo') - ) - ), - 'expected' => [ - 'id' => '2c485713-521c-4d91-b9e7-1294f132ad2e', - 'items' => [['name' => 'Macbook Pro'], ['name' => 'iPhone XYZ']], - 'createdAt' => '2000-01-01T00:00:00-02:00' - ] - ], - 'Dragon object' => [ - 'object' => new Dragon( - name: 'Ignithar Blazeheart', - type: DragonType::FIRE, - power: 10000000.00, - skills: DragonSkills::cases() - ), - 'expected' => [ - 'name' => 'Ignithar Blazeheart', - 'type' => 'FIRE', - 'power' => 10000000.00, - 'skills' => ['fly', 'spell', 'regeneration', 'elemental_breath'] - ] - ], - 'Decimal object' => [ - 'object' => new Decimal(value: 999.99), - 'expected' => ['value' => 999.99] - ], - 'Product object' => [ - 'object' => new Product( - name: 'Macbook Pro', - amount: Amount::from(value: 1600.00, currency: Currency::USD), - stockBatch: new ArrayIterator([1000, 2000, 3000]) - ), - 'expected' => [ - 'name' => 'Macbook Pro', - 'amount' => ['value' => 1600.00, 'currency' => Currency::USD->value], - 'stockBatch' => [1000, 2000, 3000] - ] - ], - 'ExpirationDate object' => [ - 'object' => new ExpirationDate( - value: new DateTimeImmutable( - '2000-01-01 00:00:00', - new DateTimeZone('UTC') - ) - ), - 'expected' => ['value' => '2000-01-01 00:00:00'] - ], - 'Shipping object with no addresses' => [ - 'object' => new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()), - 'expected' => [ - 'id' => PHP_INT_MAX, - 'addresses' => [] - ] - ], - 'Shipping object with a single address' => [ - 'object' => new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ), - 'expected' => [ - 'id' => PHP_INT_MIN, - 'addresses' => [ - [ - 'city' => 'São Paulo', - 'state' => ShippingState::SP->name, - 'street' => 'Avenida Paulista', - 'number' => 100, - 'country' => ShippingCountry::BRAZIL->value - ] - ] - ] - ], - 'Shipping object with multiple addresses' => [ - 'object' => new Shipping( - id: 100000, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: '5th Avenue', - number: 1, - country: ShippingCountry::UNITED_STATES - ), - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: 'Broadway', - number: 42, - country: ShippingCountry::UNITED_STATES - ) - ] - ) - ), - 'expected' => [ - 'id' => 100000, - 'addresses' => [ - [ - 'city' => 'New York', - 'state' => ShippingState::NY->name, - 'street' => '5th Avenue', - 'number' => 1, - 'country' => ShippingCountry::UNITED_STATES->value - ], - [ - 'city' => 'New York', - 'state' => ShippingState::NY->name, - 'street' => 'Broadway', - 'number' => 42, - 'country' => ShippingCountry::UNITED_STATES->value - ] - ] - ] - ] - ]; - } - - public static function dataProviderForIterableToObject(): iterable - { - return [ - 'Order object' => [ - 'iterable' => [ - 'id' => '2c485713-521c-4d91-b9e7-1294f132ad2e', - 'items' => [['name' => 'Macbook Pro'], ['name' => 'iPhone XYZ']], - 'createdAt' => '2000-01-01T00:00:00-02:00' - ], - 'expected' => new Order( - id: '2c485713-521c-4d91-b9e7-1294f132ad2e', - items: (static function (): Generator { - yield ['name' => 'Macbook Pro']; - yield ['name' => 'iPhone XYZ']; - })(), - createdAt: DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s', - '2000-01-01 00:00:00', - new DateTimeZone('America/Sao_Paulo') - ) - ) - ], - 'Amount object' => [ - 'iterable' => ['value' => 999.99, 'currency' => 'USD'], - 'expected' => Amount::from(value: 999.99, currency: Currency::USD) - ], - 'Decimal object' => [ - 'iterable' => ['value' => 999.99], - 'expected' => new Decimal(value: 999.99) - ], - 'Product object' => [ - 'iterable' => [ - 'name' => 'Macbook Pro', - 'amount' => ['value' => 1600.00, 'currency' => 'USD'], - 'stockBatch' => [1000, 2000, 3000] - ], - 'expected' => new Product( - name: 'Macbook Pro', - amount: Amount::from(value: 1600.00, currency: Currency::USD), - stockBatch: new ArrayIterator([1000, 2000, 3000]) - ) - ], - 'Customer object' => [ - 'iterable' => ['name' => 'Zephyrax the Tempest'], - 'expected' => new Customer(name: 'Zephyrax the Tempest', score: 0, gender: null) - ], - 'Merchant object' => [ - 'iterable' => [ - 'id' => '1dc6ca7a-e5f9-4c04-8fdf-16630c3009e3', - 'stores' => [ - ['name' => 'Store A'], - ['name' => 'Store B'] - ] - ], - 'expected' => new Merchant( - id: '1dc6ca7a-e5f9-4c04-8fdf-16630c3009e3', - stores: Stores::createFrom(elements: [ - ['name' => 'Store A'], - ['name' => 'Store B'] - ]) - ) - ], - 'Configuration object' => [ - 'iterable' => [ - 'id' => PHP_INT_MAX, - 'options' => ['ON', 'OFF'] - ], - 'expected' => new Configuration( - id: (static function (): Generator { - yield PHP_INT_MAX; - })(), - options: (static function (): Generator { - yield 'ON'; - yield 'OFF'; - })() - ) - ], - 'Shipping object with no addresses' => [ - 'iterable' => ['id' => PHP_INT_MAX, 'addresses' => []], - 'expected' => new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()) - ], - 'Shipping object with a single address' => [ - 'iterable' => [ - 'id' => PHP_INT_MIN, - 'addresses' => [ - [ - 'city' => 'São Paulo', - 'state' => 'SP', - 'street' => 'Avenida Paulista', - 'number' => 100, - 'country' => 'BR' - ] - ] - ], - 'expected' => new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ) - ], - 'Shipping object with multiple addresses' => [ - 'iterable' => [ - 'id' => PHP_INT_MIN, - 'addresses' => [ - [ - 'city' => 'New York', - 'state' => 'NY', - 'street' => '5th Avenue', - 'number' => 717, - 'country' => 'US' - ], - [ - 'city' => 'New York', - 'state' => 'NY', - 'street' => 'Broadway', - 'number' => 42, - 'country' => 'US' - ] - ] - ], - 'expected' => new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: '5th Avenue', - number: 717, - country: ShippingCountry::UNITED_STATES - ), - new ShippingAddress( - city: 'New York', - state: ShippingState::NY, - street: 'Broadway', - number: 42, - country: ShippingCountry::UNITED_STATES - ) - ] - ) - ) - ] - ]; - } - - public static function dataProviderForToJsonDiscardKeys(): iterable - { - return [ - 'Amount object' => [ - 'object' => Amount::from(value: 999.99, currency: Currency::USD), - 'expected' => '[999.99,"USD"]' - ], - 'Shipping object with a single address' => [ - 'object' => new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ), - 'expected' => '[-9223372036854775808,[["São Paulo","SP","Avenida Paulista",100,"BR"]]]' - ] - ]; - } - - public static function dataProviderForToArrayDiscardKeys(): iterable - { - return [ - 'Amount object' => [ - 'object' => Amount::from(value: 999.99, currency: Currency::USD), - 'expected' => [999.99, 'USD'] - ], - 'Shipping object with a single address' => [ - 'object' => new Shipping( - id: PHP_INT_MIN, - addresses: ShippingAddresses::createFrom( - elements: [ - new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Avenida Paulista', - number: 100, - country: ShippingCountry::BRAZIL - ) - ] - ) - ), - 'expected' => [ - PHP_INT_MIN, - [ - [ - 'São Paulo', - ShippingState::SP->name, - 'Avenida Paulista', - 100, - ShippingCountry::BRAZIL->value - ] - ] - ] - ] - ]; - } -} diff --git a/tests/ObjectMappingTest.php b/tests/ObjectMappingTest.php new file mode 100644 index 0000000..3efc221 --- /dev/null +++ b/tests/ObjectMappingTest.php @@ -0,0 +1,402 @@ +toArray(); + + /** @Then the mapped array should have expected values */ + self::assertSame($expected, $actual); + + /** @And when mapping the object to JSON */ + $actual = $object->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithGenerator(): void + { + /** @Given an Order with a Generator of items */ + $order = new Order( + id: new Uuid(value: '123e4567-e89b-12d3-a456-426614174000'), + items: (function (): Generator { + yield ['sku' => 'ITEM-001', 'quantity' => 2]; + yield ['sku' => 'ITEM-002', 'quantity' => 1]; + yield ['sku' => 'ITEM-003', 'quantity' => 5]; + })(), + createdAt: new DateTimeImmutable('2025-01-01 10:00:00', new DateTimeZone('UTC')) + ); + + /** @When mapping the Order to an array */ + $actual = $order->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'id' => '123e4567-e89b-12d3-a456-426614174000', + 'items' => [ + ['sku' => 'ITEM-001', 'quantity' => 2], + ['sku' => 'ITEM-002', 'quantity' => 1], + ['sku' => 'ITEM-003', 'quantity' => 5] + ], + 'createdAt' => '2025-01-01T10:00:00+00:00' + ]; + + self::assertSame($expected, $actual); + + /** @And when mapping the Order to JSON */ + $order = Order::fromIterable(iterable: $expected); + $actual = $order->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + #[DataProvider('objectDiscardKeysProvider')] + public function testObjectDiscardKeys(ObjectMapper $object, array $expected): void + { + /** @Given an Object */ + /** @When mapping the object to an array with discard key preservation */ + $actual = $object->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the mapped array should have expected values */ + self::assertSame($expected, $actual); + + /** @And when mapping the object to JSON with discard key preservation */ + $actual = $object->toJson(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithoutConstructor(): void + { + /** @Given a Webhook with no constructor */ + $webhook = new Webhook(); + $webhook->url = 'https://example.com/hook'; + $webhook->active = true; + + /** @When mapping the Webhook to an array */ + $actual = $webhook->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'url' => 'https://example.com/hook', + 'active' => true + ]; + + self::assertSame($expected, $actual); + + /** @And when mapping the Webhook to JSON */ + $actual = $webhook->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithStaticProperties(): void + { + /** @Given a Webhook with no constructor and static properties */ + $webhook = new Webhook(); + $webhook->url = 'https://example.com/static-test'; + $webhook->active = true; + Webhook::$timeout = 60; + + /** @When mapping the Webhook to an array */ + $actual = $webhook->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'url' => 'https://example.com/static-test', + 'active' => true + ]; + + self::assertSame($expected, $actual); + + /** @And when mapping the Webhook to JSON */ + $actual = $webhook->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithPrivateConstructor(): void + { + /** @Given an Amount object with a private constructor */ + $amount = Amount::fromIterable(iterable: ['value' => 150.75, 'currency' => 'BRL']); + + /** @When mapping the Amount to an array */ + $actual = $amount->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'value' => 150.75, + 'currency' => 'BRL' + ]; + + self::assertSame($expected, $actual); + + /** @And when mapping the Amount to JSON */ + $actual = $amount->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithNonIterableGeneratorValue(): void + { + /** @Given a Configuration object with a non-iterable Generator value */ + $configuration = Configuration::fromIterable(iterable: ['id' => 42, 'options' => 'single-option']); + + /** @When mapping the Configuration to an array */ + $actual = $configuration->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'id' => [42,], + 'options' => ['single-option'], + ]; + + self::assertSame($expected, $actual); + } + + public function testObjectWithNonTraversableProperty(): void + { + /** @Given a Catalog object with a non-traversable property */ + $catalog = new Catalog(name: 'Electronics', items: ['laptop', 'phone', 'tablet']); + + /** @When mapping the Catalog to an array */ + $actual = $catalog->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = ['laptop', 'phone', 'tablet']; + + self::assertSame($expected, $actual); + + /** @And when mapping the Catalog to JSON */ + $actual = $catalog->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithArrayIteratorNonTraversableProperty(): void + { + /** @Given an Inventory object with a non-traversable property */ + $inventory = new Inventory(stock: new ArrayIterator(['item-A', 'item-B', 'item-C'])); + + /** @When mapping the Inventory to an array */ + $actual = $inventory->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = ['item-A', 'item-B', 'item-C']; + + self::assertSame($expected, $actual); + + /** @And when mapping the Inventory to JSON */ + $actual = $inventory->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public function testObjectWithoutConstructorWithDefaultValues(): void + { + /** @Given a Webhook with no constructor using default property values */ + $webhook = new Webhook(); + + /** @When mapping the Webhook to an array */ + $actual = $webhook->toArray(); + + /** @Then the mapped array should have expected default values */ + $expected = [ + 'url' => '', + 'active' => false + ]; + + self::assertSame($expected, $actual); + + /** @And when mapping the Webhook to JSON */ + $actual = $webhook->toJson(); + + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); + } + + public static function objectProvider(): array + { + return [ + 'Tag object' => [ + 'class' => Tag::class, + 'iterable' => [], + 'expected' => [ + 'name' => '', + 'color' => 'gray' + ] + ], + 'Member object' => [ + 'class' => Member::class, + 'iterable' => [ + 'id' => new MemberId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')), + 'role' => 'owner', + 'isOwner' => true, + 'organizationId' => new OrganizationId( + value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23') + ) + ], + 'expected' => [ + 'id' => '88f15d3f-c9b9-4855-9778-5ba7926b6736', + 'role' => 'owner', + 'isOwner' => true, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ], + ], + 'Service object' => [ + 'class' => Service::class, + 'iterable' => ['action' => static fn() => 'executed'], + 'expected' => ['action' => []] + ], + 'Product object' => [ + 'class' => Product::class, + 'iterable' => [ + 'id' => 1, + 'amount' => Amount::from(value: 99.99, currency: Currency::USD), + 'description' => new Description(text: 'A high-quality product'), + 'attributes' => new ArrayIterator([ + 'color' => 'red', + 'size' => 'M', + 'inStock' => true + ]), + 'inventory' => ['stock' => 100, 'warehouse' => 'A1'], + 'status' => ProductStatus::ACTIVE, + 'createdAt' => new DateTimeImmutable('2026-01-01T10:00:00+00:00') + ], + 'expected' => [ + 'id' => 1, + 'amount' => ['value' => 99.99, 'currency' => 'USD'], + 'description' => 'A high-quality product', + 'attributes' => ['color' => 'red', 'size' => 'M', 'inStock' => true], + 'inventory' => ['stock' => 100, 'warehouse' => 'A1'], + 'status' => 1, + 'createdAt' => '2026-01-01T10:00:00+00:00' + ], + ], + 'Employee object' => [ + 'class' => Employee::class, + 'iterable' => [ + 'name' => 'John', + 'active' => false + ], + 'expected' => [ + 'name' => 'John', + 'department' => 'general', + 'active' => false + ], + ], + 'Organization object' => [ + 'class' => Organization::class, + 'iterable' => [ + 'id' => new OrganizationId( + value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23') + ), + 'name' => 'Tech Corp', + 'members' => Members::createFrom(elements: [ + new Member( + id: new MemberId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')), + role: 'owner', + isOwner: true, + organizationId: new OrganizationId( + value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23') + ) + ), + new Member( + id: new MemberId(value: new Uuid(value: 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6')), + role: 'admin', + isOwner: false, + organizationId: new OrganizationId( + value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23') + ) + ) + ]), + 'invitations' => [] + ], + 'expected' => [ + 'id' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23', + 'name' => 'Tech Corp', + 'members' => [ + [ + 'id' => '88f15d3f-c9b9-4855-9778-5ba7926b6736', + 'role' => 'owner', + 'isOwner' => true, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ], + [ + 'id' => 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6', + 'role' => 'admin', + 'isOwner' => false, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ] + ], + 'invitations' => [] + ] + ] + ]; + } + + public static function objectDiscardKeysProvider(): array + { + return [ + 'Amount object with discard keys' => [ + 'object' => Amount::fromIterable(iterable: ['value' => 100.50, 'currency' => 'USD']), + 'expected' => [100.50, 'USD'] + ], + 'Employee object with discard keys' => [ + 'object' => new Employee( + name: 'Gustavo', + department: 'Technology', + active: true + ), + 'expected' => ['Gustavo', 'Technology', true] + ] + ]; + } +}