From e0b3e65b26bc27ab97b5bcc4961738b2fe829bd9 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 7 Feb 2026 08:59:05 -0300 Subject: [PATCH 1/4] feat: Removes unreachable code from internal classes, simplify value object unwrapping, and reorganize test suite. --- .github/workflows/ci.yml | 3 - composer.json | 4 +- phpstan.neon.dist | 14 +- src/Internal/Builders/ObjectBuilder.php | 98 +++ .../Detectors/CollectibleDetector.php | 15 + src/Internal/Detectors/DateTimeDetector.php | 15 + src/Internal/Detectors/EnumDetector.php | 15 + src/Internal/Detectors/ScalarDetector.php | 13 + src/Internal/Detectors/TypeDetector.php | 19 + .../Detectors/ValueObjectDetector.php | 30 + src/Internal/Exceptions/InvalidCast.php | 1 - src/Internal/Extractors/PropertyExtractor.php | 19 + .../Extractors/ReflectionExtractor.php | 40 ++ .../Extractors/ValuePropertyExtractor.php | 20 + .../Factories/StrategyResolverFactory.php | 71 +++ src/Internal/Mappers/ArrayMapper.php | 22 + .../Mappers/Collection/ArrayMapper.php | 48 -- .../Mappers/Collection/CollectionMapper.php | 26 - .../Mappers/Collection/DateTimeMapper.php | 21 - .../Mappers/Collection/EnumMapper.php | 16 - .../Mappers/Collection/ValueMapper.php | 31 - src/Internal/Mappers/Json/JsonMapper.php | 21 - src/Internal/Mappers/JsonMapper.php | 15 + .../Object/Casters/ArrayIteratorCaster.php | 2 +- .../Mappers/Object/Casters/CasterHandler.php | 29 +- .../Mappers/Object/Casters/ClosureCaster.php | 15 + .../Object/Casters/CollectionCaster.php | 10 +- .../Mappers/Object/Casters/DateTimeCaster.php | 9 +- .../Mappers/Object/Casters/DefaultCaster.php | 4 +- .../Mappers/Object/Casters/EnumCaster.php | 7 +- .../Object/Casters/GeneratorCaster.php | 2 +- .../Mappers/Object/Casters/ObjectMapper.php | 31 + .../Object/{ => Casters}/Reflector.php | 19 +- src/Internal/Mappers/Object/ObjectMapper.php | 36 -- src/Internal/Resolvers/StrategyResolver.php | 41 ++ .../Resolvers/StrategyResolverContainer.php | 20 + .../Strategies/CollectionMappingStrategy.php | 46 ++ .../ComplexObjectMappingStrategy.php | 74 +++ .../Strategies/DateTimeMappingStrategy.php | 35 ++ .../Strategies/EnumMappingStrategy.php | 35 ++ src/Internal/Strategies/MappingStrategy.php | 37 ++ .../Strategies/ScalarMappingStrategy.php | 32 + .../Transformers/DateTimeTransformer.php | 15 + src/Internal/Transformers/EnumTransformer.php | 19 + src/Internal/Transformers/Transformer.php | 19 + .../Transformers/ValueObjectUnwrapper.php | 28 + src/IterableMappability.php | 16 +- src/KeyPreservation.php | 5 +- src/ObjectMappability.php | 25 +- tests/CollectionMappingTest.php | 157 +++++ tests/ConstructorMappingTest.php | 153 +++++ tests/EnumMappingTest.php | 164 +++++ tests/IterableMapperTest.php | 495 --------------- tests/IterableTypeMappingTest.php | 285 +++++++++ tests/KeyPreservationMappingTest.php | 148 +++++ tests/Models/Amount.php | 2 +- tests/Models/Article.php | 17 + tests/Models/Collection.php | 2 +- tests/Models/Configuration.php | 2 +- tests/Models/Currency.php | 2 +- tests/Models/Customer.php | 2 +- tests/Models/Decimal.php | 2 +- tests/Models/DeepValue.php | 16 + tests/Models/Dragon.php | 2 +- tests/Models/DragonSkills.php | 2 +- tests/Models/DragonType.php | 2 +- tests/Models/Employee.php | 20 + tests/Models/Employees.php | 19 + tests/Models/ExpirationDate.php | 18 - tests/Models/Member.php | 22 + tests/Models/MemberId.php | 17 + tests/Models/Members.php | 19 + tests/Models/Merchant.php | 4 +- tests/Models/Order.php | 2 +- tests/Models/Organization.php | 21 + tests/Models/OrganizationId.php | 17 + tests/Models/Product.php | 2 +- tests/Models/Service.php | 2 +- tests/Models/Shipping.php | 2 +- tests/Models/ShippingAddress.php | 2 +- tests/Models/ShippingAddresses.php | 2 +- tests/Models/ShippingCountry.php | 2 +- tests/Models/ShippingState.php | 2 +- tests/Models/Store.php | 20 + tests/Models/Stores.php | 2 +- tests/Models/Tag.php | 16 + tests/Models/Tags.php | 19 + tests/Models/Team.php | 17 + tests/Models/UserId.php | 17 + tests/Models/Uuid.php | 17 + tests/Models/Webhook.php | 15 + tests/ObjectMapperTest.php | 587 ------------------ tests/PropertyMappingTest.php | 136 ++++ tests/ScalarMappingTest.php | 174 ++++++ tests/ValueObjectMappingTest.php | 356 +++++++++++ 95 files changed, 2806 insertions(+), 1384 deletions(-) create mode 100644 src/Internal/Builders/ObjectBuilder.php create mode 100644 src/Internal/Detectors/CollectibleDetector.php create mode 100644 src/Internal/Detectors/DateTimeDetector.php create mode 100644 src/Internal/Detectors/EnumDetector.php create mode 100644 src/Internal/Detectors/ScalarDetector.php create mode 100644 src/Internal/Detectors/TypeDetector.php create mode 100644 src/Internal/Detectors/ValueObjectDetector.php create mode 100644 src/Internal/Extractors/PropertyExtractor.php create mode 100644 src/Internal/Extractors/ReflectionExtractor.php create mode 100644 src/Internal/Extractors/ValuePropertyExtractor.php create mode 100644 src/Internal/Factories/StrategyResolverFactory.php create mode 100644 src/Internal/Mappers/ArrayMapper.php delete mode 100644 src/Internal/Mappers/Collection/ArrayMapper.php delete mode 100644 src/Internal/Mappers/Collection/CollectionMapper.php delete mode 100644 src/Internal/Mappers/Collection/DateTimeMapper.php delete mode 100644 src/Internal/Mappers/Collection/EnumMapper.php delete mode 100644 src/Internal/Mappers/Collection/ValueMapper.php delete mode 100644 src/Internal/Mappers/Json/JsonMapper.php create mode 100644 src/Internal/Mappers/JsonMapper.php create mode 100644 src/Internal/Mappers/Object/Casters/ClosureCaster.php create mode 100644 src/Internal/Mappers/Object/Casters/ObjectMapper.php rename src/Internal/Mappers/Object/{ => Casters}/Reflector.php (54%) delete mode 100644 src/Internal/Mappers/Object/ObjectMapper.php create mode 100644 src/Internal/Resolvers/StrategyResolver.php create mode 100644 src/Internal/Resolvers/StrategyResolverContainer.php create mode 100644 src/Internal/Strategies/CollectionMappingStrategy.php create mode 100644 src/Internal/Strategies/ComplexObjectMappingStrategy.php create mode 100644 src/Internal/Strategies/DateTimeMappingStrategy.php create mode 100644 src/Internal/Strategies/EnumMappingStrategy.php create mode 100644 src/Internal/Strategies/MappingStrategy.php create mode 100644 src/Internal/Strategies/ScalarMappingStrategy.php create mode 100644 src/Internal/Transformers/DateTimeTransformer.php create mode 100644 src/Internal/Transformers/EnumTransformer.php create mode 100644 src/Internal/Transformers/Transformer.php create mode 100644 src/Internal/Transformers/ValueObjectUnwrapper.php create mode 100644 tests/CollectionMappingTest.php create mode 100644 tests/ConstructorMappingTest.php create mode 100644 tests/EnumMappingTest.php delete mode 100644 tests/IterableMapperTest.php create mode 100644 tests/IterableTypeMappingTest.php create mode 100644 tests/KeyPreservationMappingTest.php create mode 100644 tests/Models/Article.php create mode 100644 tests/Models/DeepValue.php create mode 100644 tests/Models/Employee.php create mode 100644 tests/Models/Employees.php delete mode 100644 tests/Models/ExpirationDate.php create mode 100644 tests/Models/Member.php create mode 100644 tests/Models/MemberId.php create mode 100644 tests/Models/Members.php create mode 100644 tests/Models/Organization.php create mode 100644 tests/Models/OrganizationId.php create mode 100644 tests/Models/Store.php create mode 100644 tests/Models/Tag.php create mode 100644 tests/Models/Tags.php create mode 100644 tests/Models/Team.php create mode 100644 tests/Models/UserId.php create mode 100644 tests/Models/Uuid.php create mode 100644 tests/Models/Webhook.php delete mode 100644 tests/ObjectMapperTest.php create mode 100644 tests/PropertyMappingTest.php create mode 100644 tests/ScalarMappingTest.php create mode 100644 tests/ValueObjectMappingTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e41c407..a818aca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Validate composer.json @@ -53,7 +52,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 +76,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..39c5d75 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ }, "autoload-dev": { "psr-4": { - "TinyBlocks\\Mapper\\": "tests/" + "Test\\TinyBlocks\\Mapper\\": "tests/" } }, "require": { @@ -54,7 +54,7 @@ "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..ef35c24 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,11 +4,13 @@ parameters: level: 9 tmpDir: report/phpstan ignoreErrors: - - '#method#' - - '#expects#' - - '#should return#' + - '#T of#' + - '#mixed#' + - '#template type#' + - '#ReflectionType#' + - '#generic interface#' - '#is used zero times#' - - '#type mixed supplied#' - - '#not specify its types#' - - '#no value type specified#' + - '#expects class-string#' + - '#value type specified#' + - '#not specify its types: T#' 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/CollectibleDetector.php b/src/Internal/Detectors/CollectibleDetector.php new file mode 100644 index 0000000..41bafcd --- /dev/null +++ b/src/Internal/Detectors/CollectibleDetector.php @@ -0,0 +1,15 @@ +getConstructor(); + + return $constructor !== null && $this->hasSingleValueParameter(constructor: $constructor); + } + + protected function hasSingleValueParameter(ReflectionMethod $constructor): bool + { + $parameters = $constructor->getParameters(); + + return count(value: $parameters) === self::SINGLE_PROPERTY + && $parameters[0]->getName() === self::VALUE_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/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( + filter: ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE + ); + + $extracted = []; + + foreach ($properties as $property) { + $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..b6b0004 --- /dev/null +++ b/src/Internal/Extractors/ValuePropertyExtractor.php @@ -0,0 +1,20 @@ +getProperty(self::VALUE_PROPERTY); + + return $property->getValue($object); + } +} diff --git a/src/Internal/Factories/StrategyResolverFactory.php b/src/Internal/Factories/StrategyResolverFactory.php new file mode 100644 index 0000000..d15481e --- /dev/null +++ b/src/Internal/Factories/StrategyResolverFactory.php @@ -0,0 +1,71 @@ +set(resolver: $resolver); + + return $resolver; + } +} 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..8559c08 --- /dev/null +++ b/src/Internal/Mappers/JsonMapper.php @@ -0,0 +1,15 @@ +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 = $this->parameter->getType()->getName(); + $caster = $this->resolveCaster(typeName: $typeName); return $caster->castValue(value: $value); } + + protected function resolveCaster(string $typeName): Caster + { + return match (true) { + $typeName === Closure::class => new ClosureCaster(), + $typeName === Generator::class => new GeneratorCaster(), + $typeName === ArrayIterator::class => new ArrayIteratorCaster(), + $typeName === DateTimeImmutable::class => new DateTimeCaster(), + enum_exists($typeName) => new EnumCaster(class: $typeName), + is_a($typeName, Collectible::class, true) => new CollectionCaster(class: $typeName), + default => new DefaultCaster(class: $typeName) + }; + } } diff --git a/src/Internal/Mappers/Object/Casters/ClosureCaster.php b/src/Internal/Mappers/Object/Casters/ClosureCaster.php new file mode 100644 index 0000000..b9d4e7b --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/ClosureCaster.php @@ -0,0 +1,15 @@ + $value; + } +} diff --git a/src/Internal/Mappers/Object/Casters/CollectionCaster.php b/src/Internal/Mappers/Object/Casters/CollectionCaster.php index d98d01d..854bb79 100644 --- a/src/Internal/Mappers/Object/Casters/CollectionCaster.php +++ b/src/Internal/Mappers/Object/Casters/CollectionCaster.php @@ -5,8 +5,6 @@ namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use TinyBlocks\Collection\Collectible; -use TinyBlocks\Mapper\Internal\Mappers\Object\ObjectMapper; -use TinyBlocks\Mapper\Internal\Mappers\Object\Reflector; use TinyBlocks\Mapper\IterableMapper; final readonly class CollectionCaster implements Caster @@ -17,9 +15,10 @@ public function __construct(private string $class) public function castValue(mixed $value): Collectible { - $reflectionClass = Reflector::reflectFrom(class: $this->class); + $reflector = Reflector::reflectFrom(class: $this->class); + /** @var IterableMapper & Collectible $instance */ - $instance = $reflectionClass->newInstanceWithoutConstructor(); + $instance = $reflector->newInstanceWithoutConstructor(); $type = $instance->getType(); @@ -28,9 +27,10 @@ public function castValue(mixed $value): Collectible } $mapped = []; + $mapper = new ObjectMapper(); foreach ($value as $item) { - $mapped[] = new ObjectMapper()->map(iterable: $item, class: $type); + $mapped[] = $mapper->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 index 077d91e..2694bcf 100644 --- a/src/Internal/Mappers/Object/Casters/DateTimeCaster.php +++ b/src/Internal/Mappers/Object/Casters/DateTimeCaster.php @@ -5,11 +5,16 @@ namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use DateTimeImmutable; +use DateTimeInterface; final readonly class DateTimeCaster implements Caster { - public function castValue(mixed $value): DateTimeImmutable + public function castValue(mixed $value): DateTimeInterface { - return new DateTimeImmutable($value); + if ($value instanceof DateTimeInterface) { + return $value; + } + + return new DateTimeImmutable(datetime: $value); } } diff --git a/src/Internal/Mappers/Object/Casters/DefaultCaster.php b/src/Internal/Mappers/Object/Casters/DefaultCaster.php index 3e87f61..81a3e5c 100644 --- a/src/Internal/Mappers/Object/Casters/DefaultCaster.php +++ b/src/Internal/Mappers/Object/Casters/DefaultCaster.php @@ -4,8 +4,6 @@ namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; -use TinyBlocks\Mapper\Internal\Mappers\Object\ObjectMapper; - final readonly class DefaultCaster implements Caster { public function __construct(private string $class) @@ -14,7 +12,7 @@ public function __construct(private string $class) public function castValue(mixed $value): mixed { - if (!class_exists($this->class)) { + if (!class_exists(class: $this->class)) { return $value; } diff --git a/src/Internal/Mappers/Object/Casters/EnumCaster.php b/src/Internal/Mappers/Object/Casters/EnumCaster.php index 05c8606..ef74a29 100644 --- a/src/Internal/Mappers/Object/Casters/EnumCaster.php +++ b/src/Internal/Mappers/Object/Casters/EnumCaster.php @@ -5,6 +5,7 @@ namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use ReflectionEnum; +use ReflectionEnumBackedCase; use TinyBlocks\Mapper\Internal\Exceptions\InvalidCast; use UnitEnum; @@ -16,12 +17,12 @@ public function __construct(public string $class) public function castValue(mixed $value): UnitEnum { - $reflectionEnum = new ReflectionEnum($this->class); + $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/ObjectMapper.php b/src/Internal/Mappers/Object/Casters/ObjectMapper.php new file mode 100644 index 0000000..005133a --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/ObjectMapper.php @@ -0,0 +1,31 @@ +getParameters(); + $inputProperties = iterator_to_array($iterable); + $arguments = []; + + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $value = $inputProperties[$name] ?? null; + + if ($value !== null) { + $caster = new CasterHandler(parameter: $parameter); + $arguments[] = $caster->castValue(value: $value); + continue; + } + + $arguments[] = $parameter->getDefaultValue(); + } + + return $reflector->newInstance(constructorArguments: $arguments); + } +} diff --git a/src/Internal/Mappers/Object/Reflector.php b/src/Internal/Mappers/Object/Casters/Reflector.php similarity index 54% rename from src/Internal/Mappers/Object/Reflector.php rename to src/Internal/Mappers/Object/Casters/Reflector.php index a2a9435..c33d5e6 100644 --- a/src/Internal/Mappers/Object/Reflector.php +++ b/src/Internal/Mappers/Object/Casters/Reflector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Mapper\Internal\Mappers\Object; +namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use ReflectionClass; use ReflectionMethod; @@ -10,18 +10,19 @@ final readonly class Reflector { private ?ReflectionMethod $constructor; - private array $parameters; - private function __construct(private ReflectionClass $reflectionClass) + public function __construct(private ReflectionClass $reflectionClass) { $this->constructor = $reflectionClass->getConstructor(); - $this->parameters = $this->constructor ? $this->constructor->getParameters() : []; + $this->parameters = $this->constructor instanceof ReflectionMethod + ? $this->constructor->getParameters() + : []; } public static function reflectFrom(string $class): Reflector { - return new Reflector(reflectionClass: new ReflectionClass($class)); + return new Reflector(reflectionClass: new ReflectionClass(objectOrClass: $class)); } public function getParameters(): array @@ -31,12 +32,12 @@ public function getParameters(): array public function newInstance(array $constructorArguments): object { - $instance = $this->constructor && $this->constructor->isPrivate() + $instance = $this->constructor instanceof ReflectionMethod && $this->constructor->isPrivate() ? $this->newInstanceWithoutConstructor() - : $this->reflectionClass->newInstanceArgs($constructorArguments); + : $this->reflectionClass->newInstanceArgs(args: $constructorArguments); - if ($this->constructor && $this->constructor->isPrivate()) { - $this->constructor->invokeArgs($instance, $constructorArguments); + if ($this->constructor instanceof ReflectionMethod && $this->constructor->isPrivate()) { + $this->constructor->invokeArgs(object: $instance, args: $constructorArguments); } return $instance; 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/Resolvers/StrategyResolver.php b/src/Internal/Resolvers/StrategyResolver.php new file mode 100644 index 0000000..1a31fc0 --- /dev/null +++ b/src/Internal/Resolvers/StrategyResolver.php @@ -0,0 +1,41 @@ +strategies = $this->sortByPriority(strategies: $strategies); + } + + public function resolve(mixed $value): MappingStrategy + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports(value: $value)) { + return $strategy; + } + } + + return end($this->strategies); + } + + private function sortByPriority(array $strategies): array + { + usort( + $strategies, + static fn( + MappingStrategy $current, + MappingStrategy $next + ): int => $next->priority() <=> $current->priority() + ); + + return $strategies; + } +} 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/CollectionMappingStrategy.php b/src/Internal/Strategies/CollectionMappingStrategy.php new file mode 100644 index 0000000..9900a04 --- /dev/null +++ b/src/Internal/Strategies/CollectionMappingStrategy.php @@ -0,0 +1,46 @@ + $element) { + $strategy = $this->resolverContainer + ->get() + ->resolve(value: $element); + $mapped[$key] = $strategy->map(value: $element, keyPreservation: $keyPreservation); + } + + return $keyPreservation->shouldPreserveKeys() + ? $mapped + : array_values(array: $mapped); + } + + public function supports(mixed $value): bool + { + return $this->detector->matches(value: $value); + } + + public function priority(): int + { + return self::PRIORITY; + } +} diff --git a/src/Internal/Strategies/ComplexObjectMappingStrategy.php b/src/Internal/Strategies/ComplexObjectMappingStrategy.php new file mode 100644 index 0000000..61b45bb --- /dev/null +++ b/src/Internal/Strategies/ComplexObjectMappingStrategy.php @@ -0,0 +1,74 @@ +extractor->extractProperties(object: $value); + + $mapped = array_map(function ($propertyValue) use ($keyPreservation) { + return $this->resolveValue(value: $propertyValue, keyPreservation: $keyPreservation); + }, $properties); + + return $keyPreservation->shouldPreserveKeys() + ? $mapped + : array_values($mapped); + } + + public function supports(mixed $value): bool + { + return is_object(value: $value); + } + + public function priority(): int + { + return self::PRIORITY; + } + + private function resolveValue(mixed $value, KeyPreservation $keyPreservation): mixed + { + if (is_object($value) && $this->valueObjectDetector->matches(value: $value)) { + $value = $this->unwrapper->transform(value: $value); + } + + if (is_iterable($value) && !is_array($value)) { + $value = iterator_to_array($value); + } + + if (is_array(value: $value)) { + return array_map( + fn(mixed $item): mixed => $this->resolveValue(value: $item, keyPreservation: $keyPreservation), + $value + ); + } + + if (is_object($value)) { + return $this->resolverContainer + ->get() + ->resolve(value: $value) + ->map(value: $value, keyPreservation: $keyPreservation); + } + + return $value; + } +} diff --git a/src/Internal/Strategies/DateTimeMappingStrategy.php b/src/Internal/Strategies/DateTimeMappingStrategy.php new file mode 100644 index 0000000..7e338d0 --- /dev/null +++ b/src/Internal/Strategies/DateTimeMappingStrategy.php @@ -0,0 +1,35 @@ +transformer->transform(value: $value); + } + + public function supports(mixed $value): bool + { + return $this->detector->matches(value: $value); + } + + public function priority(): int + { + return self::PRIORITY; + } +} diff --git a/src/Internal/Strategies/EnumMappingStrategy.php b/src/Internal/Strategies/EnumMappingStrategy.php new file mode 100644 index 0000000..699d6b1 --- /dev/null +++ b/src/Internal/Strategies/EnumMappingStrategy.php @@ -0,0 +1,35 @@ +transformer->transform(value: $value); + } + + public function supports(mixed $value): bool + { + return $this->detector->matches(value: $value); + } + + public function priority(): int + { + return self::PRIORITY; + } +} diff --git a/src/Internal/Strategies/MappingStrategy.php b/src/Internal/Strategies/MappingStrategy.php new file mode 100644 index 0000000..475ecab --- /dev/null +++ b/src/Internal/Strategies/MappingStrategy.php @@ -0,0 +1,37 @@ +detector->matches(value: $value); + } + + public function priority(): int + { + return self::PRIORITY; + } +} diff --git a/src/Internal/Transformers/DateTimeTransformer.php b/src/Internal/Transformers/DateTimeTransformer.php new file mode 100644 index 0000000..decf82f --- /dev/null +++ b/src/Internal/Transformers/DateTimeTransformer.php @@ -0,0 +1,15 @@ +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..2eb8d95 100644 --- a/src/IterableMappability.php +++ b/src/IterableMappability.php @@ -4,23 +4,25 @@ namespace TinyBlocks\Mapper; -use TinyBlocks\Mapper\Internal\Mappers\Collection\ArrayMapper; -use TinyBlocks\Mapper\Internal\Mappers\Json\JsonMapper; +use TinyBlocks\Mapper\Internal\Factories\StrategyResolverFactory; +use TinyBlocks\Mapper\Internal\Mappers\ArrayMapper; +use TinyBlocks\Mapper\Internal\Mappers\JsonMapper; trait IterableMappability { public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string { - $mapper = new JsonMapper(); - - return $mapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); + $jsonMapper = new JsonMapper(); + return $jsonMapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); } public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array { - $mapper = new ArrayMapper(); + $factory = new StrategyResolverFactory(); + $resolver = $factory->create(); + $arrayMapper = new ArrayMapper(resolver: $resolver); - return $mapper->map(value: $this, keyPreservation: $keyPreservation); + return $arrayMapper->map(value: $this, keyPreservation: $keyPreservation); } 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..644ccd1 100644 --- a/src/ObjectMappability.php +++ b/src/ObjectMappability.php @@ -4,30 +4,35 @@ 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\Factories\StrategyResolverFactory; +use TinyBlocks\Mapper\Internal\Mappers\ArrayMapper; +use TinyBlocks\Mapper\Internal\Mappers\JsonMapper; trait ObjectMappability { public static function fromIterable(iterable $iterable): static { - $mapper = new ObjectMapper(); + $extractor = new ReflectionExtractor(); + $builder = new ObjectBuilder(extractor: $extractor); - return $mapper->map(iterable: $iterable, class: static::class); + /** @var static */ + return $builder->build(iterable: $iterable, class: static::class); } public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string { - $mapper = new JsonMapper(); - - return $mapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); + $jsonMapper = new JsonMapper(); + return $jsonMapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); } public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array { - $mapper = new ArrayMapper(); + $factory = new StrategyResolverFactory(); + $resolver = $factory->create(); + $arrayMapper = new ArrayMapper(resolver: $resolver); - return $mapper->map(value: $this, keyPreservation: $keyPreservation); + return $arrayMapper->map(value: $this, keyPreservation: $keyPreservation); } } diff --git a/tests/CollectionMappingTest.php b/tests/CollectionMappingTest.php new file mode 100644 index 0000000..a872d49 --- /dev/null +++ b/tests/CollectionMappingTest.php @@ -0,0 +1,157 @@ +toArray(); + + /** @Then the nested collection should be converted */ + self::assertSame('merchant-123', $actual['id']); + self::assertIsArray($actual['stores']); + self::assertCount(2, $actual['stores']); + self::assertSame([ + 'id' => 'store-1', + 'name' => 'Store A', + 'active' => true + ], $actual['stores'][0]); + self::assertSame([ + 'id' => 'store-2', + 'name' => 'Store B', + 'active' => false + ], $actual['stores'][1]); + } + + public function testEmptyNestedCollection(): void + { + /** @Given a Merchant with an empty Stores collection */ + $merchant = new Merchant( + id: 'merchant-empty', + stores: Stores::createFrom(elements: []) + ); + + /** @When converting to array */ + $actual = $merchant->toArray(); + + /** @Then stores should be an empty array */ + self::assertSame([], $actual['stores']); + } + + public function testCollectionIsIterable(): void + { + /** @Given a Stores collection with elements */ + $stores = Stores::createFrom(elements: [ + new Store(id: 's1', name: 'A', active: true), + new Store(id: 's2', name: 'B', active: true) + ]); + + /** @When iterating over the collection */ + $count = 0; + foreach ($stores as $store) { + $count++; + self::assertInstanceOf(Store::class, $store); + } + + /** @Then the count should match the number of elements */ + self::assertSame(2, $count); + self::assertSame(2, $stores->count()); + } + + public function testNestedCollectionFromIterable(): void + { + /** @Given data for a Merchant with Store objects */ + $data = [ + 'id' => 'merchant-456', + 'stores' => [ + new Store(id: 'store-a', name: 'Alpha', active: true), + new Store(id: 'store-b', name: 'Beta', active: false) + ] + ]; + + /** @When creating from iterable */ + $merchant = Merchant::fromIterable(iterable: $data); + + /** @Then the Merchant should contain the Stores collection */ + $actual = $merchant->toArray(); + + self::assertSame('merchant-456', $actual['id']); + self::assertCount(2, $actual['stores']); + self::assertSame('store-a', $actual['stores'][0]['id']); + self::assertSame('store-b', $actual['stores'][1]['id']); + } + + public function testCollectionWithDefaultValuesOnElements(): void + { + /** @Given a Team with employees where some have missing optional properties */ + $data = [ + 'id' => 'team-1', + 'employees' => [ + ['name' => 'Alice', 'department' => 'engineering', 'active' => true], + ['name' => 'Bob'] + ] + ]; + + /** @When creating Team from iterable */ + $team = Team::fromIterable(iterable: $data); + + /** @Then defaults should be applied to missing properties */ + $actual = $team->toArray(); + + self::assertSame('team-1', $actual['id']); + self::assertCount(2, $actual['employees']); + + self::assertSame('Alice', $actual['employees'][0]['name']); + self::assertSame('engineering', $actual['employees'][0]['department']); + self::assertTrue($actual['employees'][0]['active']); + + self::assertSame('Bob', $actual['employees'][1]['name']); + self::assertSame('general', $actual['employees'][1]['department']); + self::assertTrue($actual['employees'][1]['active']); + } + + public function testCollectionWithNoConstructorElements(): void + { + /** @Given an Article with Tags whose element type has no constructor */ + $data = [ + 'title' => 'My Article', + 'tags' => [ + ['name' => 'php', 'color' => 'blue'], + ['name' => 'testing', 'color' => 'green'] + ] + ]; + + /** @When creating Article from iterable */ + $article = Article::fromIterable(iterable: $data); + + /** @Then Tag elements should have default values since Tag has no constructor */ + $actual = $article->toArray(); + + self::assertSame('My Article', $actual['title']); + self::assertCount(2, $actual['tags']); + self::assertSame('', $actual['tags'][0]['name']); + self::assertSame('gray', $actual['tags'][0]['color']); + self::assertSame('', $actual['tags'][1]['name']); + self::assertSame('gray', $actual['tags'][1]['color']); + } +} diff --git a/tests/ConstructorMappingTest.php b/tests/ConstructorMappingTest.php new file mode 100644 index 0000000..d8ac04e --- /dev/null +++ b/tests/ConstructorMappingTest.php @@ -0,0 +1,153 @@ +toArray(); + + /** @Then the amount should be mapped correctly */ + self::assertSame([ + 'value' => 1500.00, + 'currency' => 'USD' + ], $actual); + } + + public function testPrivateConstructorFromIterable(): void + { + /** @Given array data for Amount with a raw enum value */ + $data = [ + 'value' => 2500.50, + 'currency' => 'BRL' + ]; + + /** @When creating from iterable */ + $amount = Amount::fromIterable(iterable: $data); + + /** @Then the Amount should be created successfully */ + $actual = $amount->toArray(); + self::assertSame(2500.50, $actual['value']); + self::assertSame('BRL', $actual['currency']); + } + + public function testPrivateConstructorFromIterableWithDifferentEnum(): void + { + /** @Given array data with USD currency */ + $data = [ + 'value' => 999.99, + 'currency' => 'USD' + ]; + + /** @When creating from iterable */ + $amount = Amount::fromIterable(iterable: $data); + + /** @Then the enum should be reconstructed correctly */ + $actual = $amount->toArray(); + self::assertSame('USD', $actual['currency']); + } + + public function testPrivateConstructorCollectionToArray(): void + { + /** @Given a Collection created via factory with a private constructor */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then elements should be preserved */ + self::assertSame(['a', 'b', 'c'], $actual); + self::assertSame(3, $collection->count()); + } + + public function testPrivateConstructorCollectionWithComplexObjects(): void + { + /** @Given a Collection with Amount objects */ + $collection = Collection::createFrom(elements: [ + Amount::from(value: 100.00, currency: Currency::USD), + Amount::from(value: 200.00, currency: Currency::BRL) + ]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then amounts should be converted */ + self::assertCount(2, $actual); + self::assertSame([ + ['value' => 100.00, 'currency' => 'USD'], + ['value' => 200.00, 'currency' => 'BRL'] + ], $actual); + } + + public function testNoConstructorToArray(): void + { + /** @Given a Webhook with no constructor and assigned properties */ + $webhook = new Webhook(); + $webhook->url = 'https://example.com/hook'; + $webhook->active = true; + + /** @When converting to array */ + $actual = $webhook->toArray(); + + /** @Then properties should be mapped correctly */ + self::assertSame([ + 'url' => 'https://example.com/hook', + 'active' => true + ], $actual); + } + + public function testNoConstructorWithDefaults(): void + { + /** @Given a Webhook with no constructor using default values */ + $webhook = new Webhook(); + + /** @When converting to array */ + $actual = $webhook->toArray(); + + /** @Then default values should be mapped */ + self::assertSame([ + 'url' => '', + 'active' => false + ], $actual); + } + + public function testNoConstructorFromIterable(): void + { + /** @Given data for a Webhook with no constructor */ + $data = [ + 'url' => 'https://api.example.com/webhook', + 'active' => true + ]; + + /** @When creating from iterable */ + $webhook = Webhook::fromIterable(iterable: $data); + + /** @Then the Webhook should be created with default values */ + self::assertInstanceOf(Webhook::class, $webhook); + } + + public function testNoConstructorDefaultsToJson(): void + { + /** @Given a Webhook with all default (empty) values */ + $webhook = new Webhook(); + + /** @When converting to JSON */ + $actual = $webhook->toJson(); + + /** @Then should produce JSON with default values */ + self::assertSame('{"url":"","active":false}', $actual); + } +} diff --git a/tests/EnumMappingTest.php b/tests/EnumMappingTest.php new file mode 100644 index 0000000..0615b5d --- /dev/null +++ b/tests/EnumMappingTest.php @@ -0,0 +1,164 @@ +toArray(); + + /** @Then the enum value should be used */ + self::assertSame('BRL', $actual['currency']); + self::assertIsString($actual['currency']); + } + + public function testPureEnum(): void + { + /** @Given a Dragon with a pure enum */ + $dragon = new Dragon( + name: 'Smaug', + type: DragonType::FIRE, + power: 9999.99, + skills: [] + ); + + /** @When converting to array */ + $actual = $dragon->toArray(); + + /** @Then the enum name should be used */ + self::assertSame('FIRE', $actual['type']); + } + + public function testBackedStringEnumArray(): void + { + /** @Given a Dragon with an array of backed enums */ + $dragon = new Dragon( + name: 'Alduin', + type: DragonType::FIRE, + power: 10000.00, + skills: [ + DragonSkills::FLY, + DragonSkills::ELEMENTAL_BREATH, + DragonSkills::REGENERATION + ] + ); + + /** @When converting to array */ + $actual = $dragon->toArray(); + + /** @Then enum values should appear in the array */ + self::assertSame([ + 'fly', + 'elemental_breath', + 'regeneration' + ], $actual['skills']); + } + + public function testMixedEnumTypes(): void + { + /** @Given a ShippingAddress with both pure and backed enums */ + $address = new ShippingAddress( + city: 'São Paulo', + state: ShippingState::SP, + street: 'Av Paulista', + number: 1000, + country: ShippingCountry::BRAZIL + ); + + /** @When converting to array */ + $actual = $address->toArray(); + + /** @Then both enum types should be handled correctly */ + self::assertSame('SP', $actual['state']); + self::assertSame('BR', $actual['country']); + } + + public function testBackedEnumFromIterable(): void + { + /** @Given data with a backed enum value */ + $data = [ + 'value' => 500.00, + 'currency' => 'USD' + ]; + + /** @When creating from iterable */ + $amount = Amount::fromIterable(iterable: $data); + + /** @Then the enum should be reconstructed */ + $actual = $amount->toArray(); + self::assertSame('USD', $actual['currency']); + } + + public function testPureEnumFromIterable(): void + { + /** @Given data with a pure enum name */ + $data = [ + 'city' => 'New York', + 'state' => 'NY', + 'street' => 'Broadway', + 'number' => 100, + 'country' => 'US' + ]; + + /** @When creating from iterable */ + $address = ShippingAddress::fromIterable(iterable: $data); + + /** @Then both enums should be reconstructed */ + $actual = $address->toArray(); + self::assertSame('NY', $actual['state']); + self::assertSame('US', $actual['country']); + } + + public function testEnumArrayFromIterable(): void + { + /** @Given Dragon data with skill values */ + $data = [ + 'name' => 'Bahamut', + 'type' => 'FIRE', + 'power' => 15000.0, + 'skills' => ['fly', 'spell', 'elemental_breath'] + ]; + + /** @When creating from iterable */ + $dragon = Dragon::fromIterable(iterable: $data); + + /** @Then enums should be reconstructed */ + $actual = $dragon->toArray(); + self::assertSame('FIRE', $actual['type']); + self::assertSame(['fly', 'spell', 'elemental_breath'], $actual['skills']); + } + + public function testInvalidEnumValueThrowsException(): 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/IterableTypeMappingTest.php b/tests/IterableTypeMappingTest.php new file mode 100644 index 0000000..9fb425b --- /dev/null +++ b/tests/IterableTypeMappingTest.php @@ -0,0 +1,285 @@ +toArray(); + + /** @Then the ArrayIterator should be converted to array */ + self::assertSame('Laptop', $actual['name']); + self::assertIsArray($actual['stockBatch']); + self::assertSame([1001, 1002, 1003], $actual['stockBatch']); + } + + public function testEmptyArrayIterator(): void + { + /** @Given a Product with an empty ArrayIterator */ + $product = new Product( + name: 'Out of Stock', + amount: Amount::from(value: 99.99, currency: Currency::BRL), + stockBatch: new ArrayIterator([]) + ); + + /** @When converting to array */ + $actual = $product->toArray(); + + /** @Then stockBatch should be an empty array */ + self::assertSame([], $actual['stockBatch']); + } + + public function testArrayIteratorWithAssociativeKeys(): void + { + /** @Given an ArrayIterator with associative data */ + $product = new Product( + name: 'Phone', + amount: Amount::from(value: 800.00, currency: Currency::USD), + stockBatch: new ArrayIterator([ + 'batch1' => 100, + 'batch2' => 200, + 'batch3' => 300 + ]) + ); + + /** @When converting to array */ + $actual = $product->toArray(); + + /** @Then associative keys should be preserved */ + self::assertSame([ + 'batch1' => 100, + 'batch2' => 200, + 'batch3' => 300 + ], $actual['stockBatch']); + } + + public function testArrayIteratorFromIterable(): void + { + /** @Given product data with an array for stockBatch */ + $data = [ + 'name' => 'Tablet', + 'amount' => ['value' => 500.00, 'currency' => 'USD'], + 'stockBatch' => [5001, 5002, 5003] + ]; + + /** @When creating from iterable */ + $product = Product::fromIterable(iterable: $data); + + /** @Then the ArrayIterator should be created from the array */ + $actual = $product->toArray(); + self::assertSame([5001, 5002, 5003], $actual['stockBatch']); + } + + public function testGeneratorToArray(): void + { + /** @Given an Order with a Generator for items */ + $order = new Order( + id: 'order-123', + items: $this->createItemsGenerator(), + createdAt: new DateTimeImmutable('2025-01-01 10:00:00', new DateTimeZone('UTC')) + ); + + /** @When converting to array */ + $actual = $order->toArray(); + + /** @Then the Generator should be converted to array */ + self::assertSame('order-123', $actual['id']); + self::assertIsArray($actual['items']); + self::assertCount(3, $actual['items']); + self::assertSame('2025-01-01T10:00:00+00:00', $actual['createdAt']); + } + + public function testMultipleGenerators(): void + { + /** @Given a Configuration with multiple Generators */ + $config = new Configuration( + id: $this->createIdGenerator(), + options: $this->createOptionsGenerator() + ); + + /** @When converting to array */ + $actual = $config->toArray(); + + /** @Then both Generators should be converted to arrays */ + self::assertIsArray($actual['id']); + self::assertIsArray($actual['options']); + self::assertSame(['uuid-1', 'uuid-2', 'uuid-3'], $actual['id']); + self::assertSame(['debug' => true, 'timeout' => 30], $actual['options']); + } + + public function testEmptyGenerator(): void + { + /** @Given an Order with an empty Generator */ + $order = new Order( + id: 'empty-order', + items: $this->createEmptyGenerator(), + createdAt: new DateTimeImmutable('2025-01-01') + ); + + /** @When converting to array */ + $actual = $order->toArray(); + + /** @Then items should be an empty array */ + self::assertSame([], $actual['items']); + } + + public function testGeneratorFromIterable(): void + { + /** @Given order data with an items array */ + $data = [ + 'id' => 'order-789', + 'items' => [ + ['sku' => 'A1', 'quantity' => 5], + ['sku' => 'B2', 'quantity' => 3] + ], + 'createdAt' => '2025-02-01T15:30:00+00:00' + ]; + + /** @When creating from iterable */ + $order = Order::fromIterable(iterable: $data); + + /** @Then the order should be created with a Generator */ + $actual = $order->toArray(); + + self::assertSame('order-789', $actual['id']); + self::assertIsArray($actual['items']); + self::assertCount(2, $actual['items']); + } + + public function testGeneratorFromIterableWithDateTimeInstance(): void + { + /** @Given order data with a DateTimeImmutable instance */ + $data = [ + 'id' => 'order-999', + 'items' => [['sku' => 'C3', 'quantity' => 1]], + 'createdAt' => new DateTimeImmutable('2025-06-15T12:00:00+00:00') + ]; + + /** @When creating from iterable */ + $order = Order::fromIterable(iterable: $data); + + /** @Then the DateTimeImmutable should be preserved */ + $actual = $order->toArray(); + + self::assertSame('order-999', $actual['id']); + self::assertSame('2025-06-15T12:00:00+00:00', $actual['createdAt']); + } + + public function testGeneratorFromIterableWithScalarItems(): void + { + /** @Given order data with a single scalar value for items */ + $data = [ + 'id' => 'order-scalar', + 'items' => 'single-item', + 'createdAt' => '2025-03-01T00:00:00+00:00' + ]; + + /** @When creating from iterable */ + $order = Order::fromIterable(iterable: $data); + + /** @Then the scalar should be yielded as a single-element array */ + $actual = $order->toArray(); + + self::assertSame('order-scalar', $actual['id']); + self::assertSame(['single-item'], $actual['items']); + } + + public function testClosureToArray(): void + { + /** @Given a Service with a Closure */ + $service = new Service(action: static fn() => 'executed'); + + /** @When converting to array */ + $actual = $service->toArray(); + + /** @Then the Closure should be serialized as an empty array */ + self::assertSame(['action' => []], $actual); + } + + public function testClosureWithCapturedVariables(): void + { + /** @Given a Service with a Closure capturing variables */ + $multiplier = 5; + $service = new Service(action: static fn($x) => $x * $multiplier); + + /** @When converting to array */ + $actual = $service->toArray(); + + /** @Then the Closure should be serialized as an empty array */ + self::assertSame(['action' => []], $actual); + } + + public function testClosureFromIterable(): void + { + /** @Given data with a Closure */ + $data = ['action' => fn() => 'test']; + + /** @When creating from iterable */ + $service = Service::fromIterable(iterable: $data); + + /** @Then the Service should be created */ + $actual = $service->toArray(); + self::assertArrayHasKey('action', $actual); + } + + public function testClosureProducesEmptyArrayNotNull(): void + { + /** @Given a Service with a no-op Closure */ + $service = new Service(action: static fn() => null); + + /** @When converting to array */ + $actual = $service->toArray(); + + /** @Then action should be an empty array, not null */ + self::assertIsArray($actual['action']); + self::assertEmpty($actual['action']); + } + + private function createItemsGenerator(): Generator + { + yield ['sku' => 'ITEM-001', 'quantity' => 2]; + yield ['sku' => 'ITEM-002', 'quantity' => 1]; + yield ['sku' => 'ITEM-003', 'quantity' => 5]; + } + + private function createIdGenerator(): Generator + { + yield 'uuid-1'; + yield 'uuid-2'; + yield 'uuid-3'; + } + + private function createOptionsGenerator(): Generator + { + yield 'debug' => true; + yield 'timeout' => 30; + } + + private function createEmptyGenerator(): Generator + { + yield from []; + } +} diff --git a/tests/KeyPreservationMappingTest.php b/tests/KeyPreservationMappingTest.php new file mode 100644 index 0000000..6002ccb --- /dev/null +++ b/tests/KeyPreservationMappingTest.php @@ -0,0 +1,148 @@ + 'ten', + 20 => 'twenty', + 30 => 'thirty' + ]); + + /** @When converting with DISCARD */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then keys should be reindexed from zero */ + self::assertSame(['ten', 'twenty', 'thirty'], $actual); + self::assertSame([0, 1, 2], array_keys($actual)); + } + + public function testDiscardStringKeys(): void + { + /** @Given a collection with string keys */ + $collection = Collection::createFrom(elements: [ + 'first' => 'value1', + 'second' => 'value2', + 'third' => 'value3' + ]); + + /** @When converting with DISCARD */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then string keys should be discarded */ + self::assertSame(['value1', 'value2', 'value3'], $actual); + self::assertArrayNotHasKey('first', $actual); + self::assertArrayNotHasKey('second', $actual); + self::assertArrayNotHasKey('third', $actual); + } + + public function testPreserveStringKeys(): void + { + /** @Given a collection with string keys */ + $collection = Collection::createFrom(elements: [ + 'alpha' => 100, + 'beta' => 200, + 'gamma' => 300 + ]); + + /** @When converting with PRESERVE */ + $actual = $collection->toArray(); + + /** @Then string keys should be preserved */ + self::assertSame([ + 'alpha' => 100, + 'beta' => 200, + 'gamma' => 300 + ], $actual); + } + + public function testPreserveNumericKeys(): void + { + /** @Given a collection with non-sequential numeric keys */ + $collection = Collection::createFrom(elements: [ + 5 => 'five', + 10 => 'ten', + 15 => 'fifteen' + ]); + + /** @When converting with PRESERVE */ + $actual = $collection->toArray(); + + /** @Then original numeric keys should be preserved */ + self::assertSame([ + 5 => 'five', + 10 => 'ten', + 15 => 'fifteen' + ], $actual); + } + + public function testDiscardKeysToJson(): void + { + /** @Given a collection with string keys */ + $collection = Collection::createFrom(elements: [ + 'key1' => 'a', + 'key2' => 'b', + 'key3' => 'c' + ]); + + /** @When converting to JSON with DISCARD */ + $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); + + /** @Then JSON should be an array without keys */ + self::assertJsonStringEqualsJsonString('["a","b","c"]', $actual); + } + + public function testPreserveKeysToJson(): void + { + /** @Given a collection with string keys */ + $collection = Collection::createFrom(elements: [ + 'name' => 'John', + 'age' => 30, + 'city' => 'NYC' + ]); + + /** @When converting to JSON with PRESERVE */ + $actual = $collection->toJson(); + + /** @Then JSON should be an object with preserved keys */ + self::assertJsonStringEqualsJsonString('{"name":"John","age":30,"city":"NYC"}', $actual); + } + + public function testDefaultIsPreserve(): void + { + /** @Given a collection with keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2]); + + /** @When converting without specifying KeyPreservation */ + $actual = $collection->toArray(); + + /** @Then keys should be preserved by default */ + self::assertSame(['a' => 1, 'b' => 2], $actual); + } + + public function testDiscardKeysOnComplexObject(): void + { + /** @Given a Customer with named properties */ + $customer = new Customer(name: 'Alice', score: 100, gender: 'female'); + + /** @When converting to array with DISCARD */ + $actual = $customer->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then property names should be discarded and values indexed numerically */ + self::assertSame(['Alice', 100, 'female'], $actual); + self::assertArrayNotHasKey('name', $actual); + self::assertArrayNotHasKey('score', $actual); + self::assertArrayNotHasKey('gender', $actual); + } +} 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/Article.php b/tests/Models/Article.php new file mode 100644 index 0000000..c870d90 --- /dev/null +++ b/tests/Models/Article.php @@ -0,0 +1,17 @@ +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/PropertyMappingTest.php b/tests/PropertyMappingTest.php new file mode 100644 index 0000000..11b85ac --- /dev/null +++ b/tests/PropertyMappingTest.php @@ -0,0 +1,136 @@ +toArray(); + + /** @Then all properties should be mapped */ + self::assertSame([ + 'name' => 'John Doe', + 'score' => 100, + 'gender' => 'male' + ], $actual); + } + + public function testNullPropertyIsPreserved(): void + { + /** @Given a Customer with null gender */ + $customer = new Customer(name: 'Jane Doe', score: 85, gender: null); + + /** @When converting to array */ + $actual = $customer->toArray(); + + /** @Then null should be preserved */ + self::assertSame([ + 'name' => 'Jane Doe', + 'score' => 85, + 'gender' => null + ], $actual); + self::assertNull($actual['gender']); + } + + public function testDefaultValuesApplied(): void + { + /** @Given a Customer with only the required property */ + $customer = new Customer(name: 'Bob Smith'); + + /** @When converting to array */ + $actual = $customer->toArray(); + + /** @Then default values should be used */ + self::assertSame([ + 'name' => 'Bob Smith', + 'score' => 0, + 'gender' => null + ], $actual); + } + + public function testFromIterableWithExplicitNull(): void + { + /** @Given data with an explicit null */ + $data = [ + 'name' => 'Alice', + 'score' => 50, + 'gender' => null + ]; + + /** @When creating from iterable */ + $customer = Customer::fromIterable(iterable: $data); + + /** @Then null should be preserved */ + $actual = $customer->toArray(); + self::assertNull($actual['gender']); + self::assertArrayHasKey('gender', $actual); + } + + public function testFromIterableWithMissingOptionals(): void + { + /** @Given data without optional properties */ + $data = ['name' => 'Charlie']; + + /** @When creating from iterable */ + $customer = Customer::fromIterable(iterable: $data); + + /** @Then defaults should be applied */ + $actual = $customer->toArray(); + self::assertSame('Charlie', $actual['name']); + self::assertSame(0, $actual['score']); + self::assertNull($actual['gender']); + } + + public function testCustomToArrayOverride(): void + { + /** @Given a Decimal with a custom toArray override */ + $decimal = new Decimal(value: 123.456); + + /** @When converting to array */ + $actual = $decimal->toArray(); + + /** @Then the custom logic should be applied */ + self::assertSame(['value' => 123.456], $actual); + } + + public function testFloatTypeIsPreserved(): void + { + /** @Given a Decimal with an integer-like float */ + $decimal = new Decimal(value: 100.00); + + /** @When converting to array */ + $actual = $decimal->toArray(); + + /** @Then the float type should be preserved */ + self::assertIsFloat($actual['value']); + self::assertSame(100.00, $actual['value']); + } + + public function testCustomOverrideIgnoresKeyPreservation(): void + { + /** @Given a Decimal */ + $decimal = new Decimal(value: 999.99); + + /** @When converting with DISCARD */ + $withDiscard = $decimal->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @And converting with PRESERVE */ + $withPreserve = $decimal->toArray(); + + /** @Then both should produce the same result due to custom override */ + self::assertSame($withDiscard, $withPreserve); + self::assertSame(['value' => 999.99], $withDiscard); + } +} diff --git a/tests/ScalarMappingTest.php b/tests/ScalarMappingTest.php new file mode 100644 index 0000000..f1f8647 --- /dev/null +++ b/tests/ScalarMappingTest.php @@ -0,0 +1,174 @@ +toArray(); + + /** @Then the null should be preserved */ + self::assertSame([null], $actual); + } + + public function testMixedScalarsWithNull(): void + { + /** @Given a collection with mixed scalar types including null */ + $collection = Collection::createFrom(elements: [ + 'string', + 123, + 45.67, + true, + false, + null + ]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then all types should be preserved correctly */ + self::assertSame(['string', 123, 45.67, true, false, null], $actual); + } + + public function testOnlyNullValues(): void + { + /** @Given a collection with only null values */ + $collection = Collection::createFrom(elements: [null, null, null]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then all nulls should be preserved */ + self::assertSame([null, null, null], $actual); + self::assertCount(3, $actual); + } + + public function testNullToJson(): void + { + /** @Given a collection with null and text values */ + $collection = Collection::createFrom(elements: [null, 'text', null]); + + /** @When converting to JSON */ + $actual = $collection->toJson(); + + /** @Then nulls should appear in JSON */ + self::assertJsonStringEqualsJsonString('[null,"text",null]', $actual); + } + + public function testZeroAndEmptyStringArePersisted(): void + { + /** @Given a collection with falsy scalar values */ + $collection = Collection::createFrom(elements: [0, 0.0, '', false, null]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then each value should remain distinct */ + self::assertSame([0, 0.0, '', false, null], $actual); + } + + public function testEmptyCollection(): void + { + /** @Given an empty collection */ + $collection = Collection::createFrom(elements: []); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then result should be an empty array */ + self::assertSame([], $actual); + self::assertEmpty($actual); + } + + public function testEmptyCollectionToJson(): void + { + /** @Given an empty collection */ + $collection = Collection::createFrom(elements: []); + + /** @When converting to JSON */ + $actual = $collection->toJson(); + + /** @Then JSON should be an empty array */ + self::assertJsonStringEqualsJsonString('[]', $actual); + } + + public function testEmptyStringsArePreserved(): void + { + /** @Given a collection with empty strings */ + $collection = Collection::createFrom(elements: ['', '', '']); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then empty strings should be preserved */ + self::assertSame(['', '', ''], $actual); + self::assertCount(3, $actual); + } + + public function testEmptyArraysArePreserved(): void + { + /** @Given a collection with empty arrays */ + $collection = Collection::createFrom(elements: [[], [], []]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then empty arrays should be preserved */ + self::assertSame([[], [], []], $actual); + } + + public function testMixedEmptyValues(): void + { + /** @Given a collection with various empty values */ + $collection = Collection::createFrom(elements: ['', [], 0, false, null]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then all empty values should be preserved distinctly */ + self::assertSame(['', [], 0, false, null], $actual); + } + + public function testNestedEmptyStructures(): void + { + /** @Given a collection with nested empty structures */ + $collection = Collection::createFrom(elements: [ + ['empty' => []], + ['nested' => ['deep' => []]], + [] + ]); + + /** @When converting to array */ + $actual = $collection->toArray(); + + /** @Then the nested structure should be preserved */ + self::assertSame([ + ['empty' => []], + ['nested' => ['deep' => []]], + [] + ], $actual); + } + + public function testAllEmptyItemsToJson(): void + { + /** @Given a collection where all items are empty */ + $collection = Collection::createFrom(elements: [[], '', null, 0, false]); + + /** @When converting to JSON */ + $actual = $collection->toJson(); + + /** @Then JSON should be a valid non-empty string */ + self::assertIsString($actual); + self::assertNotEmpty($actual); + } +} diff --git a/tests/ValueObjectMappingTest.php b/tests/ValueObjectMappingTest.php new file mode 100644 index 0000000..856d433 --- /dev/null +++ b/tests/ValueObjectMappingTest.php @@ -0,0 +1,356 @@ +toArray(); + + /** @Then all Value Objects should be unwrapped to their scalar values */ + self::assertSame($expected, $actual); + } + + #[DataProvider('dataProviderForValueObjectUnwrapping')] + public function testValueObjectsAreUnwrappedInJson(Organization $organization, array $expected): void + { + /** @Given an organization with deeply nested Value Objects */ + $expected = json_encode($expected, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE); + + /** @When converting the organization to JSON */ + $actual = $organization->toJson(); + + /** @Then all Value Objects should be unwrapped to their scalar values in JSON */ + self::assertJsonStringEqualsJsonString($expected, $actual); + } + + #[DataProvider('dataProviderForFromIterable')] + public function testCreateOrganizationFromIterable(array $data, Organization $expected): void + { + /** @Given an array with organization data containing Value Object structures */ + /** @When creating an organization from the iterable */ + $actual = Organization::fromIterable(iterable: $data); + + /** @Then the organization should be created with all Value Objects properly instantiated */ + self::assertEquals($expected->toArray(), $actual->toArray()); + } + + public function testSingleLevelUnwrapping(): void + { + /** @Given a Value Object with one level of nesting */ + $userId = new UserId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')); + + /** @When converting to array */ + $actual = $userId->toArray(); + + /** @Then the Value Object should be unwrapped to its scalar value */ + self::assertSame(['value' => '88f15d3f-c9b9-4855-9778-5ba7926b6736'], $actual); + } + + public function testDoubleLevelUnwrapping(): void + { + /** @Given a Value Object with two levels of nesting */ + $organizationId = new OrganizationId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')); + + /** @When converting to array */ + $actual = $organizationId->toArray(); + + /** @Then the Value Object should be unwrapped through both levels */ + self::assertSame(['value' => '88f15d3f-c9b9-4855-9778-5ba7926b6736'], $actual); + } + + public function testDeeplyNestedUnwrapping(): void + { + /** @Given a Value Object nested 15 levels deep */ + $current = new DeepValue(value: 'scalar-at-bottom'); + + for ($i = 0; $i < 14; $i++) { + $current = new DeepValue(value: $current); + } + + /** @When converting to array */ + $actual = $current->toArray(); + + /** @Then all levels should be unwrapped to the scalar */ + self::assertSame(['value' => 'scalar-at-bottom'], $actual); + } + + public function testValueObjectWrappingArray(): void + { + /** @Given a Value Object wrapping another Value Object whose value is an array */ + $inner = new DeepValue(value: ['a', 'b', 'c']); + $outer = new DeepValue(value: $inner); + + /** @When converting to array */ + $actual = $outer->toArray(); + + /** @Then both levels should be unwrapped to the array */ + self::assertSame(['value' => ['a', 'b', 'c']], $actual); + } + + public function testValueObjectWithNullValue(): void + { + /** @Given a Value Object whose value is null */ + $deepValue = new DeepValue(value: null); + + /** @When converting to array */ + $actual = $deepValue->toArray(); + + /** @Then null should be unwrapped from the Value Object */ + self::assertSame(['value' => null], $actual); + } + + public function testNestedValueObjectWithNullAtBottom(): void + { + /** @Given a nested Value Object with null at the deepest level */ + $inner = new DeepValue(value: null); + $outer = new DeepValue(value: $inner); + + /** @When converting to array */ + $actual = $outer->toArray(); + + /** @Then null should be unwrapped through all levels */ + self::assertSame(['value' => null], $actual); + } + + public function testComplexObjectWithMultipleValueObjects(): void + { + /** @Given a complex object with multiple deeply nested Value Objects */ + $member = new Member( + id: new MemberId(value: new Uuid(value: 'member-uuid')), + role: 'admin', + userId: new UserId(value: new Uuid(value: 'user-uuid')), + isOwner: true, + organizationId: new OrganizationId(value: new Uuid(value: 'org-uuid')) + ); + + /** @When converting to array */ + $actual = $member->toArray(); + + /** @Then all Value Objects should be unwrapped to scalars */ + self::assertSame([ + 'id' => 'member-uuid', + 'role' => 'admin', + 'userId' => 'user-uuid', + 'isOwner' => true, + 'organizationId' => 'org-uuid' + ], $actual); + } + + public function testCollectionWithValueObjects(): void + { + /** @Given a collection of members with nested Value Objects */ + $members = Members::createFrom(elements: [ + new Member( + id: new MemberId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')), + role: 'admin', + userId: new UserId(value: new Uuid(value: '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8')), + isOwner: true, + organizationId: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a')) + ), + new Member( + id: new MemberId(value: new Uuid(value: 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6')), + role: 'viewer', + userId: new UserId(value: new Uuid(value: 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036')), + isOwner: false, + organizationId: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a')) + ) + ]); + + /** @When converting the collection to array */ + $actual = $members->toArray(); + + /** @Then all nested Value Objects should be unwrapped */ + self::assertSame([ + [ + 'id' => '88f15d3f-c9b9-4855-9778-5ba7926b6736', + 'role' => 'admin', + 'userId' => '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8', + 'isOwner' => true, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a' + ], + [ + 'id' => 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6', + 'role' => 'viewer', + 'userId' => 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036', + 'isOwner' => false, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a' + ] + ], $actual); + } + + public function testKeyPreservationWithValueObjects(): void + { + /** @Given an organization with Value Objects */ + $organization = new Organization( + id: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23')), + name: 'Test Org', + members: Members::createFromEmpty(), + invitations: [] + ); + + /** @When converting to array with PRESERVE keys */ + $actual = $organization->toArray(); + + /** @Then all keys should be preserved */ + self::assertArrayHasKey('id', $actual); + self::assertArrayHasKey('name', $actual); + self::assertArrayHasKey('members', $actual); + self::assertArrayHasKey('invitations', $actual); + } + + public static function dataProviderForValueObjectUnwrapping(): iterable + { + return [ + 'Organization with no members' => [ + 'organization' => new Organization( + id: new OrganizationId(value: new Uuid(value: 'empty-org')), + name: 'Empty Org', + members: Members::createFromEmpty(), + invitations: [] + ), + 'expected' => [ + 'id' => 'empty-org', + 'name' => 'Empty Org', + 'members' => [], + 'invitations' => [] + ] + ], + 'Organization with single member' => [ + 'organization' => new Organization( + id: new OrganizationId(value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b')), + name: 'Calenvo', + members: Members::createFrom(elements: [ + new Member( + id: new MemberId(value: new Uuid(value: '08a6ce33-95e7-43db-b566-9620216cdd5a')), + role: 'admin', + userId: new UserId(value: new Uuid(value: '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2')), + isOwner: true, + organizationId: new OrganizationId( + value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b') + ) + ) + ]), + invitations: [] + ), + 'expected' => [ + 'id' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b', + 'name' => 'Calenvo', + 'members' => [ + [ + 'id' => '08a6ce33-95e7-43db-b566-9620216cdd5a', + 'role' => 'admin', + 'userId' => '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2', + 'isOwner' => true, + 'organizationId' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b' + ] + ], + 'invitations' => [] + ] + ], + 'Organization with multiple members' => [ + 'organization' => new Organization( + 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', + userId: new UserId(value: new Uuid(value: '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8')), + 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', + userId: new UserId(value: new Uuid(value: 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036')), + 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', + 'userId' => '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8', + 'isOwner' => true, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ], + [ + 'id' => 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6', + 'role' => 'admin', + 'userId' => 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036', + 'isOwner' => false, + 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' + ] + ], + 'invitations' => [] + ] + ], + ]; + } + + public static function dataProviderForFromIterable(): iterable + { + return [ + 'Create organization from array with nested data' => [ + 'data' => [ + 'id' => ['value' => ['value' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b']], + 'name' => 'Calenvo', + 'members' => [ + [ + 'id' => ['value' => ['value' => '08a6ce33-95e7-43db-b566-9620216cdd5a']], + 'role' => 'admin', + 'userId' => ['value' => ['value' => '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2']], + 'isOwner' => true, + 'organizationId' => ['value' => ['value' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b']] + ] + ], + 'invitations' => [] + ], + 'expected' => new Organization( + id: new OrganizationId(value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b')), + name: 'Calenvo', + members: Members::createFrom(elements: [ + new Member( + id: new MemberId(value: new Uuid(value: '08a6ce33-95e7-43db-b566-9620216cdd5a')), + role: 'admin', + userId: new UserId(value: new Uuid(value: '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2')), + isOwner: true, + organizationId: new OrganizationId( + value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b') + ) + ) + ]), + invitations: [] + ) + ] + ]; + } +} From 1b43ced49a89359b67acbe9d4e9b389230bd22cf Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 9 Feb 2026 07:58:36 -0300 Subject: [PATCH 2/4] feat: Removes unreachable code from internal classes, simplify value object unwrapping, and reorganize test suite. --- .github/workflows/ci.yml | 1 - composer.json | 6 - .../Detectors/CollectibleDetector.php | 15 - .../Detectors/ValueObjectDetector.php | 18 +- src/Internal/Extractors/IterableExtractor.php | 68 +++ .../Extractors/ReflectionExtractor.php | 8 +- .../Extractors/ValuePropertyExtractor.php | 7 +- .../Factories/StrategyResolverFactory.php | 37 +- .../Mappers/Object/Casters/CasterHandler.php | 14 +- .../Object/Casters/CollectionCaster.php | 38 -- .../Resolvers/RecursiveValueResolver.php | 49 +++ .../Strategies/CollectionMappingStrategy.php | 46 -- .../ComplexObjectMappingStrategy.php | 45 +- .../Strategies/IterableMappingStrategy.php | 44 ++ tests/CollectionMappingTest.php | 400 +++++++++++++----- tests/ConstructorMappingTest.php | 153 ------- tests/EnumMappingTest.php | 152 ++++--- tests/IterableTypeMappingTest.php | 285 ------------- tests/KeyPreservationMappingTest.php | 148 ------- tests/Models/Article.php | 17 - .../{ShippingState.php => Articles.php} | 4 +- tests/Models/Attributes.php | 21 + tests/Models/Collection.php | 127 +----- tests/Models/Color.php | 12 + tests/Models/Configuration.php | 18 - tests/Models/Country.php | 11 + tests/Models/Customer.php | 17 - tests/Models/Decimal.php | 23 - tests/Models/DeepValue.php | 16 - tests/Models/Description.php | 12 + tests/Models/Employees.php | 8 +- tests/Models/Member.php | 1 - tests/Models/Members.php | 8 +- tests/Models/Merchant.php | 17 - tests/Models/Order.php | 2 +- tests/Models/Product.php | 12 +- tests/Models/ProductStatus.php | 11 + .../{ShippingAddresses.php => Products.php} | 9 +- tests/Models/Shipping.php | 17 - tests/Models/ShippingAddress.php | 22 - tests/Models/ShippingCountry.php | 11 - tests/Models/Store.php | 20 - tests/Models/Stores.php | 139 ------ tests/Models/Tags.php | 8 +- tests/Models/Team.php | 17 - tests/Models/UserId.php | 17 - tests/Models/Uuid.php | 7 +- tests/Models/Webhook.php | 2 + tests/ObjectMappingTest.php | 250 +++++++++++ tests/PropertyMappingTest.php | 136 ------ tests/ScalarMappingTest.php | 174 -------- tests/ValueObjectMappingTest.php | 356 ---------------- 52 files changed, 921 insertions(+), 2135 deletions(-) delete mode 100644 src/Internal/Detectors/CollectibleDetector.php create mode 100644 src/Internal/Extractors/IterableExtractor.php delete mode 100644 src/Internal/Mappers/Object/Casters/CollectionCaster.php create mode 100644 src/Internal/Resolvers/RecursiveValueResolver.php delete mode 100644 src/Internal/Strategies/CollectionMappingStrategy.php create mode 100644 src/Internal/Strategies/IterableMappingStrategy.php delete mode 100644 tests/ConstructorMappingTest.php delete mode 100644 tests/IterableTypeMappingTest.php delete mode 100644 tests/KeyPreservationMappingTest.php delete mode 100644 tests/Models/Article.php rename tests/Models/{ShippingState.php => Articles.php} (63%) create mode 100644 tests/Models/Attributes.php create mode 100644 tests/Models/Color.php delete mode 100644 tests/Models/Configuration.php create mode 100644 tests/Models/Country.php delete mode 100644 tests/Models/Customer.php delete mode 100644 tests/Models/Decimal.php delete mode 100644 tests/Models/DeepValue.php create mode 100644 tests/Models/Description.php delete mode 100644 tests/Models/Merchant.php create mode 100644 tests/Models/ProductStatus.php rename tests/Models/{ShippingAddresses.php => Products.php} (57%) delete mode 100644 tests/Models/Shipping.php delete mode 100644 tests/Models/ShippingAddress.php delete mode 100644 tests/Models/ShippingCountry.php delete mode 100644 tests/Models/Store.php delete mode 100644 tests/Models/Stores.php delete mode 100644 tests/Models/Team.php delete mode 100644 tests/Models/UserId.php create mode 100644 tests/ObjectMappingTest.php delete mode 100644 tests/PropertyMappingTest.php delete mode 100644 tests/ScalarMappingTest.php delete mode 100644 tests/ValueObjectMappingTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a818aca..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: diff --git a/composer.json b/composer.json index 39c5d75..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": { @@ -53,7 +48,6 @@ "phpunit/phpunit": "^11.5", "phpstan/phpstan": "^2.1", "infection/infection": "^0.32", - "tiny-blocks/collection": "1.10.*", "squizlabs/php_codesniffer": "^4.0" }, "scripts": { diff --git a/src/Internal/Detectors/CollectibleDetector.php b/src/Internal/Detectors/CollectibleDetector.php deleted file mode 100644 index 41bafcd..0000000 --- a/src/Internal/Detectors/CollectibleDetector.php +++ /dev/null @@ -1,15 +0,0 @@ -getConstructor(); - - return $constructor !== null && $this->hasSingleValueParameter(constructor: $constructor); - } + if (!is_object($value)) { + return false; + } - protected function hasSingleValueParameter(ReflectionMethod $constructor): bool - { - $parameters = $constructor->getParameters(); + $reflection = new ReflectionClass($value); + $properties = $reflection->getProperties(); - return count(value: $parameters) === self::SINGLE_PROPERTY - && $parameters[0]->getName() === self::VALUE_PROPERTY; + return count($properties) === self::SINGLE_PROPERTY; } } diff --git a/src/Internal/Extractors/IterableExtractor.php b/src/Internal/Extractors/IterableExtractor.php new file mode 100644 index 0000000..831e470 --- /dev/null +++ b/src/Internal/Extractors/IterableExtractor.php @@ -0,0 +1,68 @@ +fromMapper(mapper: $object); + + if ($iterable !== null) { + return match (true) { + is_array($iterable) => $iterable, + $iterable instanceof Traversable => iterator_to_array($iterable), + }; + } + } + + if ($object instanceof Traversable) { + return iterator_to_array($object); + } + + return []; + } + + private function fromMapper(IterableMapper $mapper): ?iterable + { + if ($mapper instanceof Traversable) { + return $mapper; + } + + if (method_exists($mapper, 'getIterator')) { + return $mapper->getIterator(); + } + + return $this->fromProperties(mapper: $mapper); + } + + private function fromProperties(IterableMapper $mapper): ?iterable + { + $properties = $this->extractor->extractProperties(object: $mapper); + + $elements = $properties['elements'] ?? null; + + if (is_array($elements) || $elements instanceof Traversable) { + return $elements; + } + + $candidates = array_filter( + $properties, + static fn(mixed $value): bool => is_array($value) || $value instanceof Traversable + ); + + return count($candidates) === 1 + ? reset($candidates) + : null; + } +} diff --git a/src/Internal/Extractors/ReflectionExtractor.php b/src/Internal/Extractors/ReflectionExtractor.php index 35d0be5..5b74230 100644 --- a/src/Internal/Extractors/ReflectionExtractor.php +++ b/src/Internal/Extractors/ReflectionExtractor.php @@ -13,12 +13,18 @@ public function extractProperties(object $object): array { $reflection = new ReflectionClass(objectOrClass: $object); $properties = $reflection->getProperties( - filter: ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE + filter: 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); } diff --git a/src/Internal/Extractors/ValuePropertyExtractor.php b/src/Internal/Extractors/ValuePropertyExtractor.php index b6b0004..c37abe7 100644 --- a/src/Internal/Extractors/ValuePropertyExtractor.php +++ b/src/Internal/Extractors/ValuePropertyExtractor.php @@ -8,13 +8,8 @@ final readonly class ValuePropertyExtractor implements PropertyExtractor { - private const string VALUE_PROPERTY = 'value'; - public function extract(object $object): mixed { - $reflection = new ReflectionClass($object); - $property = $reflection->getProperty(self::VALUE_PROPERTY); - - return $property->getValue($object); + return new ReflectionClass($object)->getProperties()[0]->getValue($object); } } diff --git a/src/Internal/Factories/StrategyResolverFactory.php b/src/Internal/Factories/StrategyResolverFactory.php index d15481e..d77573a 100644 --- a/src/Internal/Factories/StrategyResolverFactory.php +++ b/src/Internal/Factories/StrategyResolverFactory.php @@ -4,19 +4,20 @@ namespace TinyBlocks\Mapper\Internal\Factories; -use TinyBlocks\Mapper\Internal\Detectors\CollectibleDetector; use TinyBlocks\Mapper\Internal\Detectors\DateTimeDetector; use TinyBlocks\Mapper\Internal\Detectors\EnumDetector; use TinyBlocks\Mapper\Internal\Detectors\ScalarDetector; use TinyBlocks\Mapper\Internal\Detectors\ValueObjectDetector; +use TinyBlocks\Mapper\Internal\Extractors\IterableExtractor; use TinyBlocks\Mapper\Internal\Extractors\ReflectionExtractor; use TinyBlocks\Mapper\Internal\Extractors\ValuePropertyExtractor; +use TinyBlocks\Mapper\Internal\Resolvers\RecursiveValueResolver; use TinyBlocks\Mapper\Internal\Resolvers\StrategyResolver; use TinyBlocks\Mapper\Internal\Resolvers\StrategyResolverContainer; -use TinyBlocks\Mapper\Internal\Strategies\CollectionMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\ComplexObjectMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\DateTimeMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\EnumMappingStrategy; +use TinyBlocks\Mapper\Internal\Strategies\IterableMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\ScalarMappingStrategy; use TinyBlocks\Mapper\Internal\Transformers\DateTimeTransformer; use TinyBlocks\Mapper\Internal\Transformers\EnumTransformer; @@ -26,41 +27,39 @@ { public function create(): StrategyResolver { - $enumDetector = new EnumDetector(); - $scalarDetector = new ScalarDetector(); - $dateTimeDetector = new DateTimeDetector(); - $valueObjectDetector = new ValueObjectDetector(); - $collectibleDetector = new CollectibleDetector(); - $reflectionExtractor = new ReflectionExtractor(); - $valuePropertyExtractor = new ValuePropertyExtractor(); + $valueObjectDetector = new ValueObjectDetector(); $valueObjectUnwrapper = new ValueObjectUnwrapper( - extractor: $valuePropertyExtractor, + extractor: new ValuePropertyExtractor(), valueObjectDetector: $valueObjectDetector ); $resolverContainer = new StrategyResolverContainer(); + $recursiveValueResolver = new RecursiveValueResolver( + unwrapper: $valueObjectUnwrapper, + resolverContainer: $resolverContainer, + valueObjectDetector: $valueObjectDetector + ); + $resolver = new StrategyResolver( new EnumMappingStrategy( - detector: $enumDetector, + detector: new EnumDetector(), transformer: new EnumTransformer() ), - new ScalarMappingStrategy(detector: $scalarDetector), + new ScalarMappingStrategy(detector: new ScalarDetector()), new DateTimeMappingStrategy( - detector: $dateTimeDetector, + detector: new DateTimeDetector(), transformer: new DateTimeTransformer() ), - new CollectionMappingStrategy( - detector: $collectibleDetector, - resolverContainer: $resolverContainer + new IterableMappingStrategy( + extractor: new IterableExtractor(extractor: $reflectionExtractor), + resolver: $recursiveValueResolver ), new ComplexObjectMappingStrategy( extractor: $reflectionExtractor, - unwrapper: $valueObjectUnwrapper, - resolverContainer: $resolverContainer, - valueObjectDetector: $valueObjectDetector + resolver: $recursiveValueResolver ) ); diff --git a/src/Internal/Mappers/Object/Casters/CasterHandler.php b/src/Internal/Mappers/Object/Casters/CasterHandler.php index cccadf4..8d35f5e 100644 --- a/src/Internal/Mappers/Object/Casters/CasterHandler.php +++ b/src/Internal/Mappers/Object/Casters/CasterHandler.php @@ -9,7 +9,6 @@ use DateTimeImmutable; use Generator; use ReflectionParameter; -use TinyBlocks\Collection\Collectible; final readonly class CasterHandler { @@ -28,13 +27,12 @@ public function castValue(mixed $value): mixed protected function resolveCaster(string $typeName): Caster { return match (true) { - $typeName === Closure::class => new ClosureCaster(), - $typeName === Generator::class => new GeneratorCaster(), - $typeName === ArrayIterator::class => new ArrayIteratorCaster(), - $typeName === DateTimeImmutable::class => new DateTimeCaster(), - enum_exists($typeName) => new EnumCaster(class: $typeName), - is_a($typeName, Collectible::class, true) => new CollectionCaster(class: $typeName), - default => new DefaultCaster(class: $typeName) + $typeName === Closure::class => new ClosureCaster(), + $typeName === Generator::class => new GeneratorCaster(), + $typeName === ArrayIterator::class => new ArrayIteratorCaster(), + $typeName === DateTimeImmutable::class => new DateTimeCaster(), + enum_exists($typeName) => new EnumCaster(class: $typeName), + default => new DefaultCaster(class: $typeName) }; } } diff --git a/src/Internal/Mappers/Object/Casters/CollectionCaster.php b/src/Internal/Mappers/Object/Casters/CollectionCaster.php deleted file mode 100644 index 854bb79..0000000 --- a/src/Internal/Mappers/Object/Casters/CollectionCaster.php +++ /dev/null @@ -1,38 +0,0 @@ -class); - - /** @var IterableMapper & Collectible $instance */ - $instance = $reflector->newInstanceWithoutConstructor(); - - $type = $instance->getType(); - - if ($type === $this->class) { - return $instance->createFrom(elements: $value); - } - - $mapped = []; - $mapper = new ObjectMapper(); - - foreach ($value as $item) { - $mapped[] = $mapper->map(iterable: $item, class: $type); - } - - return $instance->createFrom(elements: $mapped); - } -} 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/Strategies/CollectionMappingStrategy.php b/src/Internal/Strategies/CollectionMappingStrategy.php deleted file mode 100644 index 9900a04..0000000 --- a/src/Internal/Strategies/CollectionMappingStrategy.php +++ /dev/null @@ -1,46 +0,0 @@ - $element) { - $strategy = $this->resolverContainer - ->get() - ->resolve(value: $element); - $mapped[$key] = $strategy->map(value: $element, keyPreservation: $keyPreservation); - } - - return $keyPreservation->shouldPreserveKeys() - ? $mapped - : array_values(array: $mapped); - } - - public function supports(mixed $value): bool - { - return $this->detector->matches(value: $value); - } - - public function priority(): int - { - return self::PRIORITY; - } -} diff --git a/src/Internal/Strategies/ComplexObjectMappingStrategy.php b/src/Internal/Strategies/ComplexObjectMappingStrategy.php index 61b45bb..d3d7a38 100644 --- a/src/Internal/Strategies/ComplexObjectMappingStrategy.php +++ b/src/Internal/Strategies/ComplexObjectMappingStrategy.php @@ -4,10 +4,8 @@ namespace TinyBlocks\Mapper\Internal\Strategies; -use TinyBlocks\Mapper\Internal\Detectors\ValueObjectDetector; use TinyBlocks\Mapper\Internal\Extractors\ReflectionExtractor; -use TinyBlocks\Mapper\Internal\Resolvers\StrategyResolverContainer; -use TinyBlocks\Mapper\Internal\Transformers\ValueObjectUnwrapper; +use TinyBlocks\Mapper\Internal\Resolvers\RecursiveValueResolver; use TinyBlocks\Mapper\KeyPreservation; final readonly class ComplexObjectMappingStrategy implements MappingStrategy @@ -16,9 +14,7 @@ public function __construct( private ReflectionExtractor $extractor, - private ValueObjectUnwrapper $unwrapper, - private StrategyResolverContainer $resolverContainer, - private ValueObjectDetector $valueObjectDetector + private RecursiveValueResolver $resolver ) { } @@ -26,9 +22,13 @@ public function map(mixed $value, KeyPreservation $keyPreservation = KeyPreserva { $properties = $this->extractor->extractProperties(object: $value); - $mapped = array_map(function ($propertyValue) use ($keyPreservation) { - return $this->resolveValue(value: $propertyValue, keyPreservation: $keyPreservation); - }, $properties); + $mapped = array_map( + fn(mixed $propertyValue): mixed => $this->resolver->resolve( + value: $propertyValue, + keyPreservation: $keyPreservation + ), + $properties + ); return $keyPreservation->shouldPreserveKeys() ? $mapped @@ -44,31 +44,4 @@ public function priority(): int { return self::PRIORITY; } - - private function resolveValue(mixed $value, KeyPreservation $keyPreservation): mixed - { - if (is_object($value) && $this->valueObjectDetector->matches(value: $value)) { - $value = $this->unwrapper->transform(value: $value); - } - - if (is_iterable($value) && !is_array($value)) { - $value = iterator_to_array($value); - } - - if (is_array(value: $value)) { - return array_map( - fn(mixed $item): mixed => $this->resolveValue(value: $item, keyPreservation: $keyPreservation), - $value - ); - } - - if (is_object($value)) { - return $this->resolverContainer - ->get() - ->resolve(value: $value) - ->map(value: $value, keyPreservation: $keyPreservation); - } - - return $value; - } } diff --git a/src/Internal/Strategies/IterableMappingStrategy.php b/src/Internal/Strategies/IterableMappingStrategy.php new file mode 100644 index 0000000..b692229 --- /dev/null +++ b/src/Internal/Strategies/IterableMappingStrategy.php @@ -0,0 +1,44 @@ + $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 is_array($value) + || $value instanceof Traversable + || $value instanceof IterableMapper; + } + + public function priority(): int + { + return self::PRIORITY; + } +} diff --git a/tests/CollectionMappingTest.php b/tests/CollectionMappingTest.php index a872d49..d081251 100644 --- a/tests/CollectionMappingTest.php +++ b/tests/CollectionMappingTest.php @@ -4,154 +4,324 @@ namespace Test\TinyBlocks\Mapper; +use ArrayIterator; +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Test\TinyBlocks\Mapper\Models\Article; -use Test\TinyBlocks\Mapper\Models\Merchant; -use Test\TinyBlocks\Mapper\Models\Store; -use Test\TinyBlocks\Mapper\Models\Stores; -use Test\TinyBlocks\Mapper\Models\Team; +use Test\TinyBlocks\Mapper\Models\Amount; +use Test\TinyBlocks\Mapper\Models\Articles; +use Test\TinyBlocks\Mapper\Models\Attributes; +use Test\TinyBlocks\Mapper\Models\Collection; +use Test\TinyBlocks\Mapper\Models\Country; +use Test\TinyBlocks\Mapper\Models\Currency; +use Test\TinyBlocks\Mapper\Models\Description; +use Test\TinyBlocks\Mapper\Models\Employee; +use Test\TinyBlocks\Mapper\Models\Employees; +use Test\TinyBlocks\Mapper\Models\Member; +use Test\TinyBlocks\Mapper\Models\MemberId; +use Test\TinyBlocks\Mapper\Models\Members; +use Test\TinyBlocks\Mapper\Models\OrganizationId; +use Test\TinyBlocks\Mapper\Models\Product; +use Test\TinyBlocks\Mapper\Models\Products; +use Test\TinyBlocks\Mapper\Models\ProductStatus; +use Test\TinyBlocks\Mapper\Models\Tag; +use Test\TinyBlocks\Mapper\Models\Tags; +use Test\TinyBlocks\Mapper\Models\Uuid; +use TinyBlocks\Mapper\IterableMapper; +use TinyBlocks\Mapper\KeyPreservation; final class CollectionMappingTest extends TestCase { - public function testNestedCollectionToArray(): void + public function testCollectionIsEmpty(): void { - /** @Given a Merchant with a nested Stores collection */ - $merchant = new Merchant( - id: 'merchant-123', - stores: Stores::createFrom(elements: [ - new Store(id: 'store-1', name: 'Store A', active: true), - new Store(id: 'store-2', name: 'Store B', active: false) - ]) - ); + /** @Given an empty Employees collection */ + $employees = Employees::createFrom(elements: []); + + /** @When mapping the Employees collection to an array */ + $actual = $employees->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()); - /** @When converting to array */ - $actual = $merchant->toArray(); - - /** @Then the nested collection should be converted */ - self::assertSame('merchant-123', $actual['id']); - self::assertIsArray($actual['stores']); - self::assertCount(2, $actual['stores']); - self::assertSame([ - 'id' => 'store-1', - 'name' => 'Store A', - 'active' => true - ], $actual['stores'][0]); - self::assertSame([ - 'id' => 'store-2', - 'name' => 'Store B', - 'active' => false - ], $actual['stores'][1]); + /** @And the Employees collection should have expected type and count */ + self::assertSame(0, $employees->count()); + self::assertSame(Employee::class, $employees->getType()); } - public function testEmptyNestedCollection(): void + #[DataProvider('collectionOfObjectsProvider')] + public function testCollectionOfObjects(string $type, IterableMapper $collection, array $expected): void { - /** @Given a Merchant with an empty Stores collection */ - $merchant = new Merchant( - id: 'merchant-empty', - stores: Stores::createFrom(elements: []) - ); + /** @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); - /** @When converting to array */ - $actual = $merchant->toArray(); + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $collection->toJson()); - /** @Then stores should be an empty array */ - self::assertSame([], $actual['stores']); + /** @And the Collection should have expected type */ + self::assertSame($type, $collection->getType()); } - public function testCollectionIsIterable(): void + public function testCollectionOfScalars(): void { - /** @Given a Stores collection with elements */ - $stores = Stores::createFrom(elements: [ - new Store(id: 's1', name: 'A', active: true), - new Store(id: 's2', name: 'B', active: true) - ]); - - /** @When iterating over the collection */ - $count = 0; - foreach ($stores as $store) { - $count++; - self::assertInstanceOf(Store::class, $store); - } - - /** @Then the count should match the number of elements */ - self::assertSame(2, $count); - self::assertSame(2, $stores->count()); + /** @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()); } - public function testNestedCollectionFromIterable(): void + #[DataProvider('collectionDiscardKeysProvider')] + public function testCollectionDiscardKeys(IterableMapper $collection, array $expected): void { - /** @Given data for a Merchant with Store objects */ - $data = [ - 'id' => 'merchant-456', - 'stores' => [ - new Store(id: 'store-a', name: 'Alpha', active: true), - new Store(id: 'store-b', name: 'Beta', active: false) - ] - ]; + /** @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 creating from iterable */ - $merchant = Merchant::fromIterable(iterable: $data); + /** @When mapping the Collection to an array */ + $actual = $collection->toArray(); - /** @Then the Merchant should contain the Stores collection */ - $actual = $merchant->toArray(); + /** @Then the mapped array should have expected values */ + $expected = [['id' => 1], ['id' => 2]]; - self::assertSame('merchant-456', $actual['id']); - self::assertCount(2, $actual['stores']); - self::assertSame('store-a', $actual['stores'][0]['id']); - self::assertSame('store-b', $actual['stores'][1]['id']); + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $collection->toJson()); } - public function testCollectionWithDefaultValuesOnElements(): void + public function testCollectionWithNoConstructorElements(): void { - /** @Given a Team with employees where some have missing optional properties */ - $data = [ - 'id' => 'team-1', - 'employees' => [ - ['name' => 'Alice', 'department' => 'engineering', 'active' => true], - ['name' => 'Bob'] - ] - ]; + /** @Given a Tags collection with a default Tag object */ + $tags = Tags::createFrom(elements: [new Tag()]); - /** @When creating Team from iterable */ - $team = Team::fromIterable(iterable: $data); + /** @When mapping the Tags collection to an array */ + $actual = $tags->toArray(); - /** @Then defaults should be applied to missing properties */ - $actual = $team->toArray(); + /** @Then the mapped array should have expected values */ + $expected = [['name' => '', 'color' => 'gray']]; - self::assertSame('team-1', $actual['id']); - self::assertCount(2, $actual['employees']); + self::assertSame($expected, $actual); - self::assertSame('Alice', $actual['employees'][0]['name']); - self::assertSame('engineering', $actual['employees'][0]['department']); - self::assertTrue($actual['employees'][0]['active']); + /** @And the JSON representation should be the mapped JSON array */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $tags->toJson()); - self::assertSame('Bob', $actual['employees'][1]['name']); - self::assertSame('general', $actual['employees'][1]['department']); - self::assertTrue($actual['employees'][1]['active']); + /** @And the Tags collection should have expected type and count */ + self::assertSame(1, $tags->count()); + self::assertSame(Tag::class, $tags->getType()); } - public function testCollectionWithNoConstructorElements(): void + public static function collectionOfObjectsProvider(): iterable { - /** @Given an Article with Tags whose element type has no constructor */ - $data = [ - 'title' => 'My Article', - 'tags' => [ - ['name' => 'php', 'color' => 'blue'], - ['name' => 'testing', 'color' => 'green'] + 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] + ] ] ]; + } - /** @When creating Article from iterable */ - $article = Article::fromIterable(iterable: $data); - - /** @Then Tag elements should have default values since Tag has no constructor */ - $actual = $article->toArray(); - - self::assertSame('My Article', $actual['title']); - self::assertCount(2, $actual['tags']); - self::assertSame('', $actual['tags'][0]['name']); - self::assertSame('gray', $actual['tags'][0]['color']); - self::assertSame('', $actual['tags'][1]['name']); - self::assertSame('gray', $actual['tags'][1]['color']); + 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/ConstructorMappingTest.php b/tests/ConstructorMappingTest.php deleted file mode 100644 index d8ac04e..0000000 --- a/tests/ConstructorMappingTest.php +++ /dev/null @@ -1,153 +0,0 @@ -toArray(); - - /** @Then the amount should be mapped correctly */ - self::assertSame([ - 'value' => 1500.00, - 'currency' => 'USD' - ], $actual); - } - - public function testPrivateConstructorFromIterable(): void - { - /** @Given array data for Amount with a raw enum value */ - $data = [ - 'value' => 2500.50, - 'currency' => 'BRL' - ]; - - /** @When creating from iterable */ - $amount = Amount::fromIterable(iterable: $data); - - /** @Then the Amount should be created successfully */ - $actual = $amount->toArray(); - self::assertSame(2500.50, $actual['value']); - self::assertSame('BRL', $actual['currency']); - } - - public function testPrivateConstructorFromIterableWithDifferentEnum(): void - { - /** @Given array data with USD currency */ - $data = [ - 'value' => 999.99, - 'currency' => 'USD' - ]; - - /** @When creating from iterable */ - $amount = Amount::fromIterable(iterable: $data); - - /** @Then the enum should be reconstructed correctly */ - $actual = $amount->toArray(); - self::assertSame('USD', $actual['currency']); - } - - public function testPrivateConstructorCollectionToArray(): void - { - /** @Given a Collection created via factory with a private constructor */ - $collection = Collection::createFrom(elements: ['a', 'b', 'c']); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then elements should be preserved */ - self::assertSame(['a', 'b', 'c'], $actual); - self::assertSame(3, $collection->count()); - } - - public function testPrivateConstructorCollectionWithComplexObjects(): void - { - /** @Given a Collection with Amount objects */ - $collection = Collection::createFrom(elements: [ - Amount::from(value: 100.00, currency: Currency::USD), - Amount::from(value: 200.00, currency: Currency::BRL) - ]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then amounts should be converted */ - self::assertCount(2, $actual); - self::assertSame([ - ['value' => 100.00, 'currency' => 'USD'], - ['value' => 200.00, 'currency' => 'BRL'] - ], $actual); - } - - public function testNoConstructorToArray(): void - { - /** @Given a Webhook with no constructor and assigned properties */ - $webhook = new Webhook(); - $webhook->url = 'https://example.com/hook'; - $webhook->active = true; - - /** @When converting to array */ - $actual = $webhook->toArray(); - - /** @Then properties should be mapped correctly */ - self::assertSame([ - 'url' => 'https://example.com/hook', - 'active' => true - ], $actual); - } - - public function testNoConstructorWithDefaults(): void - { - /** @Given a Webhook with no constructor using default values */ - $webhook = new Webhook(); - - /** @When converting to array */ - $actual = $webhook->toArray(); - - /** @Then default values should be mapped */ - self::assertSame([ - 'url' => '', - 'active' => false - ], $actual); - } - - public function testNoConstructorFromIterable(): void - { - /** @Given data for a Webhook with no constructor */ - $data = [ - 'url' => 'https://api.example.com/webhook', - 'active' => true - ]; - - /** @When creating from iterable */ - $webhook = Webhook::fromIterable(iterable: $data); - - /** @Then the Webhook should be created with default values */ - self::assertInstanceOf(Webhook::class, $webhook); - } - - public function testNoConstructorDefaultsToJson(): void - { - /** @Given a Webhook with all default (empty) values */ - $webhook = new Webhook(); - - /** @When converting to JSON */ - $actual = $webhook->toJson(); - - /** @Then should produce JSON with default values */ - self::assertSame('{"url":"","active":false}', $actual); - } -} diff --git a/tests/EnumMappingTest.php b/tests/EnumMappingTest.php index 0615b5d..bfae255 100644 --- a/tests/EnumMappingTest.php +++ b/tests/EnumMappingTest.php @@ -10,29 +10,13 @@ use Test\TinyBlocks\Mapper\Models\Dragon; use Test\TinyBlocks\Mapper\Models\DragonSkills; use Test\TinyBlocks\Mapper\Models\DragonType; -use Test\TinyBlocks\Mapper\Models\ShippingAddress; -use Test\TinyBlocks\Mapper\Models\ShippingCountry; -use Test\TinyBlocks\Mapper\Models\ShippingState; use TinyBlocks\Mapper\Internal\Exceptions\InvalidCast; final class EnumMappingTest extends TestCase { - public function testBackedStringEnum(): void - { - /** @Given an Amount with a backed string enum */ - $amount = Amount::from(value: 100.00, currency: Currency::BRL); - - /** @When converting to array */ - $actual = $amount->toArray(); - - /** @Then the enum value should be used */ - self::assertSame('BRL', $actual['currency']); - self::assertIsString($actual['currency']); - } - public function testPureEnum(): void { - /** @Given a Dragon with a pure enum */ + /** @Given a Dragon with a pure enum type */ $dragon = new Dragon( name: 'Smaug', type: DragonType::FIRE, @@ -40,16 +24,46 @@ public function testPureEnum(): void skills: [] ); - /** @When converting to array */ + /** @When mapping the Dragon to an array */ $actual = $dragon->toArray(); - /** @Then the enum name should be used */ - self::assertSame('FIRE', $actual['type']); + /** @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 testBackedStringEnum(): void + { + /** @Given an Amount with a backed string enum */ + $amount = Amount::from(value: 100.00, currency: Currency::BRL); + + /** @When mapping the Amount to an array */ + $actual = $amount->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ + 'value' => 100.00, + 'currency' => 'BRL' + ]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $amount->toJson()); } public function testBackedStringEnumArray(): void { - /** @Given a Dragon with an array of backed enums */ + /** @Given a Dragon with an array of backed string enums */ $dragon = new Dragon( name: 'Alduin', type: DragonType::FIRE, @@ -61,89 +75,71 @@ public function testBackedStringEnumArray(): void ] ); - /** @When converting to array */ + /** @When mapping the Dragon to an array */ $actual = $dragon->toArray(); - /** @Then enum values should appear in the array */ - self::assertSame([ - 'fly', - 'elemental_breath', - 'regeneration' - ], $actual['skills']); - } - - public function testMixedEnumTypes(): void - { - /** @Given a ShippingAddress with both pure and backed enums */ - $address = new ShippingAddress( - city: 'São Paulo', - state: ShippingState::SP, - street: 'Av Paulista', - number: 1000, - country: ShippingCountry::BRAZIL - ); + /** @Then the mapped array should have expected values */ + $expected = [ + 'name' => 'Alduin', + 'type' => 'FIRE', + 'power' => 10000.00, + 'skills' => ['fly', 'elemental_breath', 'regeneration'] + ]; - /** @When converting to array */ - $actual = $address->toArray(); + self::assertSame($expected, $actual); - /** @Then both enum types should be handled correctly */ - self::assertSame('SP', $actual['state']); - self::assertSame('BR', $actual['country']); + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); } public function testBackedEnumFromIterable(): void { - /** @Given data with a backed enum value */ - $data = [ + /** @Given an Amount created from iterable data with a backed enum value */ + $amount = Amount::fromIterable(iterable: [ 'value' => 500.00, 'currency' => 'USD' - ]; - - /** @When creating from iterable */ - $amount = Amount::fromIterable(iterable: $data); + ]); - /** @Then the enum should be reconstructed */ + /** @When mapping the Amount to an array */ $actual = $amount->toArray(); - self::assertSame('USD', $actual['currency']); - } - public function testPureEnumFromIterable(): void - { - /** @Given data with a pure enum name */ - $data = [ - 'city' => 'New York', - 'state' => 'NY', - 'street' => 'Broadway', - 'number' => 100, - 'country' => 'US' + /** @Then the mapped array should have expected values */ + $expected = [ + 'value' => 500.00, + 'currency' => 'USD' ]; - /** @When creating from iterable */ - $address = ShippingAddress::fromIterable(iterable: $data); + self::assertSame($expected, $actual); - /** @Then both enums should be reconstructed */ - $actual = $address->toArray(); - self::assertSame('NY', $actual['state']); - self::assertSame('US', $actual['country']); + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $amount->toJson()); } public function testEnumArrayFromIterable(): void { - /** @Given Dragon data with skill values */ - $data = [ + /** @Given a Dragon created from iterable data with enum values as strings */ + $dragon = Dragon::fromIterable(iterable: [ + 'name' => 'Bahamut', + 'type' => 'FIRE', + 'power' => 15000.00, + 'skills' => ['fly', 'spell', 'elemental_breath'] + ]); + + /** @When mapping the Dragon to an array */ + $actual = $dragon->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = [ 'name' => 'Bahamut', 'type' => 'FIRE', - 'power' => 15000.0, + 'power' => 15000.00, 'skills' => ['fly', 'spell', 'elemental_breath'] ]; - /** @When creating from iterable */ - $dragon = Dragon::fromIterable(iterable: $data); + self::assertSame($expected, $actual); - /** @Then enums should be reconstructed */ - $actual = $dragon->toArray(); - self::assertSame('FIRE', $actual['type']); - self::assertSame(['fly', 'spell', 'elemental_breath'], $actual['skills']); + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); } public function testInvalidEnumValueThrowsException(): void diff --git a/tests/IterableTypeMappingTest.php b/tests/IterableTypeMappingTest.php deleted file mode 100644 index 9fb425b..0000000 --- a/tests/IterableTypeMappingTest.php +++ /dev/null @@ -1,285 +0,0 @@ -toArray(); - - /** @Then the ArrayIterator should be converted to array */ - self::assertSame('Laptop', $actual['name']); - self::assertIsArray($actual['stockBatch']); - self::assertSame([1001, 1002, 1003], $actual['stockBatch']); - } - - public function testEmptyArrayIterator(): void - { - /** @Given a Product with an empty ArrayIterator */ - $product = new Product( - name: 'Out of Stock', - amount: Amount::from(value: 99.99, currency: Currency::BRL), - stockBatch: new ArrayIterator([]) - ); - - /** @When converting to array */ - $actual = $product->toArray(); - - /** @Then stockBatch should be an empty array */ - self::assertSame([], $actual['stockBatch']); - } - - public function testArrayIteratorWithAssociativeKeys(): void - { - /** @Given an ArrayIterator with associative data */ - $product = new Product( - name: 'Phone', - amount: Amount::from(value: 800.00, currency: Currency::USD), - stockBatch: new ArrayIterator([ - 'batch1' => 100, - 'batch2' => 200, - 'batch3' => 300 - ]) - ); - - /** @When converting to array */ - $actual = $product->toArray(); - - /** @Then associative keys should be preserved */ - self::assertSame([ - 'batch1' => 100, - 'batch2' => 200, - 'batch3' => 300 - ], $actual['stockBatch']); - } - - public function testArrayIteratorFromIterable(): void - { - /** @Given product data with an array for stockBatch */ - $data = [ - 'name' => 'Tablet', - 'amount' => ['value' => 500.00, 'currency' => 'USD'], - 'stockBatch' => [5001, 5002, 5003] - ]; - - /** @When creating from iterable */ - $product = Product::fromIterable(iterable: $data); - - /** @Then the ArrayIterator should be created from the array */ - $actual = $product->toArray(); - self::assertSame([5001, 5002, 5003], $actual['stockBatch']); - } - - public function testGeneratorToArray(): void - { - /** @Given an Order with a Generator for items */ - $order = new Order( - id: 'order-123', - items: $this->createItemsGenerator(), - createdAt: new DateTimeImmutable('2025-01-01 10:00:00', new DateTimeZone('UTC')) - ); - - /** @When converting to array */ - $actual = $order->toArray(); - - /** @Then the Generator should be converted to array */ - self::assertSame('order-123', $actual['id']); - self::assertIsArray($actual['items']); - self::assertCount(3, $actual['items']); - self::assertSame('2025-01-01T10:00:00+00:00', $actual['createdAt']); - } - - public function testMultipleGenerators(): void - { - /** @Given a Configuration with multiple Generators */ - $config = new Configuration( - id: $this->createIdGenerator(), - options: $this->createOptionsGenerator() - ); - - /** @When converting to array */ - $actual = $config->toArray(); - - /** @Then both Generators should be converted to arrays */ - self::assertIsArray($actual['id']); - self::assertIsArray($actual['options']); - self::assertSame(['uuid-1', 'uuid-2', 'uuid-3'], $actual['id']); - self::assertSame(['debug' => true, 'timeout' => 30], $actual['options']); - } - - public function testEmptyGenerator(): void - { - /** @Given an Order with an empty Generator */ - $order = new Order( - id: 'empty-order', - items: $this->createEmptyGenerator(), - createdAt: new DateTimeImmutable('2025-01-01') - ); - - /** @When converting to array */ - $actual = $order->toArray(); - - /** @Then items should be an empty array */ - self::assertSame([], $actual['items']); - } - - public function testGeneratorFromIterable(): void - { - /** @Given order data with an items array */ - $data = [ - 'id' => 'order-789', - 'items' => [ - ['sku' => 'A1', 'quantity' => 5], - ['sku' => 'B2', 'quantity' => 3] - ], - 'createdAt' => '2025-02-01T15:30:00+00:00' - ]; - - /** @When creating from iterable */ - $order = Order::fromIterable(iterable: $data); - - /** @Then the order should be created with a Generator */ - $actual = $order->toArray(); - - self::assertSame('order-789', $actual['id']); - self::assertIsArray($actual['items']); - self::assertCount(2, $actual['items']); - } - - public function testGeneratorFromIterableWithDateTimeInstance(): void - { - /** @Given order data with a DateTimeImmutable instance */ - $data = [ - 'id' => 'order-999', - 'items' => [['sku' => 'C3', 'quantity' => 1]], - 'createdAt' => new DateTimeImmutable('2025-06-15T12:00:00+00:00') - ]; - - /** @When creating from iterable */ - $order = Order::fromIterable(iterable: $data); - - /** @Then the DateTimeImmutable should be preserved */ - $actual = $order->toArray(); - - self::assertSame('order-999', $actual['id']); - self::assertSame('2025-06-15T12:00:00+00:00', $actual['createdAt']); - } - - public function testGeneratorFromIterableWithScalarItems(): void - { - /** @Given order data with a single scalar value for items */ - $data = [ - 'id' => 'order-scalar', - 'items' => 'single-item', - 'createdAt' => '2025-03-01T00:00:00+00:00' - ]; - - /** @When creating from iterable */ - $order = Order::fromIterable(iterable: $data); - - /** @Then the scalar should be yielded as a single-element array */ - $actual = $order->toArray(); - - self::assertSame('order-scalar', $actual['id']); - self::assertSame(['single-item'], $actual['items']); - } - - public function testClosureToArray(): void - { - /** @Given a Service with a Closure */ - $service = new Service(action: static fn() => 'executed'); - - /** @When converting to array */ - $actual = $service->toArray(); - - /** @Then the Closure should be serialized as an empty array */ - self::assertSame(['action' => []], $actual); - } - - public function testClosureWithCapturedVariables(): void - { - /** @Given a Service with a Closure capturing variables */ - $multiplier = 5; - $service = new Service(action: static fn($x) => $x * $multiplier); - - /** @When converting to array */ - $actual = $service->toArray(); - - /** @Then the Closure should be serialized as an empty array */ - self::assertSame(['action' => []], $actual); - } - - public function testClosureFromIterable(): void - { - /** @Given data with a Closure */ - $data = ['action' => fn() => 'test']; - - /** @When creating from iterable */ - $service = Service::fromIterable(iterable: $data); - - /** @Then the Service should be created */ - $actual = $service->toArray(); - self::assertArrayHasKey('action', $actual); - } - - public function testClosureProducesEmptyArrayNotNull(): void - { - /** @Given a Service with a no-op Closure */ - $service = new Service(action: static fn() => null); - - /** @When converting to array */ - $actual = $service->toArray(); - - /** @Then action should be an empty array, not null */ - self::assertIsArray($actual['action']); - self::assertEmpty($actual['action']); - } - - private function createItemsGenerator(): Generator - { - yield ['sku' => 'ITEM-001', 'quantity' => 2]; - yield ['sku' => 'ITEM-002', 'quantity' => 1]; - yield ['sku' => 'ITEM-003', 'quantity' => 5]; - } - - private function createIdGenerator(): Generator - { - yield 'uuid-1'; - yield 'uuid-2'; - yield 'uuid-3'; - } - - private function createOptionsGenerator(): Generator - { - yield 'debug' => true; - yield 'timeout' => 30; - } - - private function createEmptyGenerator(): Generator - { - yield from []; - } -} diff --git a/tests/KeyPreservationMappingTest.php b/tests/KeyPreservationMappingTest.php deleted file mode 100644 index 6002ccb..0000000 --- a/tests/KeyPreservationMappingTest.php +++ /dev/null @@ -1,148 +0,0 @@ - 'ten', - 20 => 'twenty', - 30 => 'thirty' - ]); - - /** @When converting with DISCARD */ - $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); - - /** @Then keys should be reindexed from zero */ - self::assertSame(['ten', 'twenty', 'thirty'], $actual); - self::assertSame([0, 1, 2], array_keys($actual)); - } - - public function testDiscardStringKeys(): void - { - /** @Given a collection with string keys */ - $collection = Collection::createFrom(elements: [ - 'first' => 'value1', - 'second' => 'value2', - 'third' => 'value3' - ]); - - /** @When converting with DISCARD */ - $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); - - /** @Then string keys should be discarded */ - self::assertSame(['value1', 'value2', 'value3'], $actual); - self::assertArrayNotHasKey('first', $actual); - self::assertArrayNotHasKey('second', $actual); - self::assertArrayNotHasKey('third', $actual); - } - - public function testPreserveStringKeys(): void - { - /** @Given a collection with string keys */ - $collection = Collection::createFrom(elements: [ - 'alpha' => 100, - 'beta' => 200, - 'gamma' => 300 - ]); - - /** @When converting with PRESERVE */ - $actual = $collection->toArray(); - - /** @Then string keys should be preserved */ - self::assertSame([ - 'alpha' => 100, - 'beta' => 200, - 'gamma' => 300 - ], $actual); - } - - public function testPreserveNumericKeys(): void - { - /** @Given a collection with non-sequential numeric keys */ - $collection = Collection::createFrom(elements: [ - 5 => 'five', - 10 => 'ten', - 15 => 'fifteen' - ]); - - /** @When converting with PRESERVE */ - $actual = $collection->toArray(); - - /** @Then original numeric keys should be preserved */ - self::assertSame([ - 5 => 'five', - 10 => 'ten', - 15 => 'fifteen' - ], $actual); - } - - public function testDiscardKeysToJson(): void - { - /** @Given a collection with string keys */ - $collection = Collection::createFrom(elements: [ - 'key1' => 'a', - 'key2' => 'b', - 'key3' => 'c' - ]); - - /** @When converting to JSON with DISCARD */ - $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); - - /** @Then JSON should be an array without keys */ - self::assertJsonStringEqualsJsonString('["a","b","c"]', $actual); - } - - public function testPreserveKeysToJson(): void - { - /** @Given a collection with string keys */ - $collection = Collection::createFrom(elements: [ - 'name' => 'John', - 'age' => 30, - 'city' => 'NYC' - ]); - - /** @When converting to JSON with PRESERVE */ - $actual = $collection->toJson(); - - /** @Then JSON should be an object with preserved keys */ - self::assertJsonStringEqualsJsonString('{"name":"John","age":30,"city":"NYC"}', $actual); - } - - public function testDefaultIsPreserve(): void - { - /** @Given a collection with keys */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2]); - - /** @When converting without specifying KeyPreservation */ - $actual = $collection->toArray(); - - /** @Then keys should be preserved by default */ - self::assertSame(['a' => 1, 'b' => 2], $actual); - } - - public function testDiscardKeysOnComplexObject(): void - { - /** @Given a Customer with named properties */ - $customer = new Customer(name: 'Alice', score: 100, gender: 'female'); - - /** @When converting to array with DISCARD */ - $actual = $customer->toArray(keyPreservation: KeyPreservation::DISCARD); - - /** @Then property names should be discarded and values indexed numerically */ - self::assertSame(['Alice', 100, 'female'], $actual); - self::assertArrayNotHasKey('name', $actual); - self::assertArrayNotHasKey('score', $actual); - self::assertArrayNotHasKey('gender', $actual); - } -} diff --git a/tests/Models/Article.php b/tests/Models/Article.php deleted file mode 100644 index c870d90..0000000 --- a/tests/Models/Article.php +++ /dev/null @@ -1,17 +0,0 @@ -elements; + } +} diff --git a/tests/Models/Collection.php b/tests/Models/Collection.php index e6781c9..cb145d5 100644 --- a/tests/Models/Collection.php +++ b/tests/Models/Collection.php @@ -4,136 +4,35 @@ namespace Test\TinyBlocks\Mapper\Models; -use Closure; -use TinyBlocks\Collection\Collectible; -use TinyBlocks\Collection\Order; +use Countable; use TinyBlocks\Mapper\IterableMappability; use TinyBlocks\Mapper\IterableMapper; -use Traversable; -final readonly class Collection implements Collectible, IterableMapper +class Collection implements Countable, IterableMapper { use IterableMappability; - private iterable $iterator; - - private function __construct(iterable $iterator) - { - $this->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/DeepValue.php b/tests/Models/DeepValue.php deleted file mode 100644 index f154642..0000000 --- a/tests/Models/DeepValue.php +++ /dev/null @@ -1,16 +0,0 @@ -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/Tags.php b/tests/Models/Tags.php index 619591a..383eb16 100644 --- a/tests/Models/Tags.php +++ b/tests/Models/Tags.php @@ -4,14 +4,8 @@ namespace Test\TinyBlocks\Mapper\Models; -use TinyBlocks\Collection\Collection; -use TinyBlocks\Mapper\IterableMappability; -use TinyBlocks\Mapper\IterableMapper; - -final class Tags extends Collection implements IterableMapper +final class Tags extends Collection { - use IterableMappability; - public function getType(): string { return Tag::class; diff --git a/tests/Models/Team.php b/tests/Models/Team.php deleted file mode 100644 index 9d26c08..0000000 --- a/tests/Models/Team.php +++ /dev/null @@ -1,17 +0,0 @@ -toArray(); + + /** @Then the mapped array should have expected values */ + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $object->toJson()); + } + + public function testObjectWithClosure(): void + { + /** @Given a Service with a Closure property */ + $service = new Service(action: static fn() => 'executed'); + + /** @When mapping the Service to an array */ + $actual = $service->toArray(); + + /** @Then the mapped array should have expected values */ + $expected = ['action' => []]; + + self::assertSame($expected, $actual); + + /** @And the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $service->toJson()); + } + + 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 the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), json_encode($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 the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $webhook->toJson()); + } + + 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 the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $webhook->toJson()); + } + + 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 the JSON representation should be the mapped JSON object */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $webhook->toJson()); + } + + public static function objectProvider(): array + { + return [ + 'Tag object' => [ + 'object' => new Tag(), + 'expected' => ['name' => '', 'color' => 'gray'] + ], + 'Service object' => [ + 'object' => Service::fromIterable(iterable: ['action' => static fn() => 'executed']), + 'expected' => ['action' => []] + ], + 'Product object' => [ + 'object' => new Product( + id: 1, + amount: Amount::from(value: 49.90, currency: Currency::USD), + description: new Description(text: 'Wireless Mouse'), + attributes: new ArrayIterator([ + 'color' => Color::BLUE, + 'weight' => 0.12, + 'wireless' => true + ]), + inventory: [10, 25, 50], + status: ProductStatus::ACTIVE, + createdAt: new DateTimeImmutable('2026-01-15T08:30:00+00:00') + ), + 'expected' => [ + 'id' => 1, + 'amount' => [ + 'value' => 49.90, + 'currency' => 'USD' + ], + 'description' => 'Wireless Mouse', + 'attributes' => [ + 'color' => 'blue', + 'weight' => 0.12, + 'wireless' => true + ], + 'inventory' => [10, 25, 50], + 'status' => 1, + 'createdAt' => '2026-01-15T08:30:00+00:00' + ] + ], + 'Organization object' => [ + 'object' => new Organization( + 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' => [] + ] + ] + ]; + } +} diff --git a/tests/PropertyMappingTest.php b/tests/PropertyMappingTest.php deleted file mode 100644 index 11b85ac..0000000 --- a/tests/PropertyMappingTest.php +++ /dev/null @@ -1,136 +0,0 @@ -toArray(); - - /** @Then all properties should be mapped */ - self::assertSame([ - 'name' => 'John Doe', - 'score' => 100, - 'gender' => 'male' - ], $actual); - } - - public function testNullPropertyIsPreserved(): void - { - /** @Given a Customer with null gender */ - $customer = new Customer(name: 'Jane Doe', score: 85, gender: null); - - /** @When converting to array */ - $actual = $customer->toArray(); - - /** @Then null should be preserved */ - self::assertSame([ - 'name' => 'Jane Doe', - 'score' => 85, - 'gender' => null - ], $actual); - self::assertNull($actual['gender']); - } - - public function testDefaultValuesApplied(): void - { - /** @Given a Customer with only the required property */ - $customer = new Customer(name: 'Bob Smith'); - - /** @When converting to array */ - $actual = $customer->toArray(); - - /** @Then default values should be used */ - self::assertSame([ - 'name' => 'Bob Smith', - 'score' => 0, - 'gender' => null - ], $actual); - } - - public function testFromIterableWithExplicitNull(): void - { - /** @Given data with an explicit null */ - $data = [ - 'name' => 'Alice', - 'score' => 50, - 'gender' => null - ]; - - /** @When creating from iterable */ - $customer = Customer::fromIterable(iterable: $data); - - /** @Then null should be preserved */ - $actual = $customer->toArray(); - self::assertNull($actual['gender']); - self::assertArrayHasKey('gender', $actual); - } - - public function testFromIterableWithMissingOptionals(): void - { - /** @Given data without optional properties */ - $data = ['name' => 'Charlie']; - - /** @When creating from iterable */ - $customer = Customer::fromIterable(iterable: $data); - - /** @Then defaults should be applied */ - $actual = $customer->toArray(); - self::assertSame('Charlie', $actual['name']); - self::assertSame(0, $actual['score']); - self::assertNull($actual['gender']); - } - - public function testCustomToArrayOverride(): void - { - /** @Given a Decimal with a custom toArray override */ - $decimal = new Decimal(value: 123.456); - - /** @When converting to array */ - $actual = $decimal->toArray(); - - /** @Then the custom logic should be applied */ - self::assertSame(['value' => 123.456], $actual); - } - - public function testFloatTypeIsPreserved(): void - { - /** @Given a Decimal with an integer-like float */ - $decimal = new Decimal(value: 100.00); - - /** @When converting to array */ - $actual = $decimal->toArray(); - - /** @Then the float type should be preserved */ - self::assertIsFloat($actual['value']); - self::assertSame(100.00, $actual['value']); - } - - public function testCustomOverrideIgnoresKeyPreservation(): void - { - /** @Given a Decimal */ - $decimal = new Decimal(value: 999.99); - - /** @When converting with DISCARD */ - $withDiscard = $decimal->toArray(keyPreservation: KeyPreservation::DISCARD); - - /** @And converting with PRESERVE */ - $withPreserve = $decimal->toArray(); - - /** @Then both should produce the same result due to custom override */ - self::assertSame($withDiscard, $withPreserve); - self::assertSame(['value' => 999.99], $withDiscard); - } -} diff --git a/tests/ScalarMappingTest.php b/tests/ScalarMappingTest.php deleted file mode 100644 index f1f8647..0000000 --- a/tests/ScalarMappingTest.php +++ /dev/null @@ -1,174 +0,0 @@ -toArray(); - - /** @Then the null should be preserved */ - self::assertSame([null], $actual); - } - - public function testMixedScalarsWithNull(): void - { - /** @Given a collection with mixed scalar types including null */ - $collection = Collection::createFrom(elements: [ - 'string', - 123, - 45.67, - true, - false, - null - ]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then all types should be preserved correctly */ - self::assertSame(['string', 123, 45.67, true, false, null], $actual); - } - - public function testOnlyNullValues(): void - { - /** @Given a collection with only null values */ - $collection = Collection::createFrom(elements: [null, null, null]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then all nulls should be preserved */ - self::assertSame([null, null, null], $actual); - self::assertCount(3, $actual); - } - - public function testNullToJson(): void - { - /** @Given a collection with null and text values */ - $collection = Collection::createFrom(elements: [null, 'text', null]); - - /** @When converting to JSON */ - $actual = $collection->toJson(); - - /** @Then nulls should appear in JSON */ - self::assertJsonStringEqualsJsonString('[null,"text",null]', $actual); - } - - public function testZeroAndEmptyStringArePersisted(): void - { - /** @Given a collection with falsy scalar values */ - $collection = Collection::createFrom(elements: [0, 0.0, '', false, null]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then each value should remain distinct */ - self::assertSame([0, 0.0, '', false, null], $actual); - } - - public function testEmptyCollection(): void - { - /** @Given an empty collection */ - $collection = Collection::createFrom(elements: []); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then result should be an empty array */ - self::assertSame([], $actual); - self::assertEmpty($actual); - } - - public function testEmptyCollectionToJson(): void - { - /** @Given an empty collection */ - $collection = Collection::createFrom(elements: []); - - /** @When converting to JSON */ - $actual = $collection->toJson(); - - /** @Then JSON should be an empty array */ - self::assertJsonStringEqualsJsonString('[]', $actual); - } - - public function testEmptyStringsArePreserved(): void - { - /** @Given a collection with empty strings */ - $collection = Collection::createFrom(elements: ['', '', '']); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then empty strings should be preserved */ - self::assertSame(['', '', ''], $actual); - self::assertCount(3, $actual); - } - - public function testEmptyArraysArePreserved(): void - { - /** @Given a collection with empty arrays */ - $collection = Collection::createFrom(elements: [[], [], []]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then empty arrays should be preserved */ - self::assertSame([[], [], []], $actual); - } - - public function testMixedEmptyValues(): void - { - /** @Given a collection with various empty values */ - $collection = Collection::createFrom(elements: ['', [], 0, false, null]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then all empty values should be preserved distinctly */ - self::assertSame(['', [], 0, false, null], $actual); - } - - public function testNestedEmptyStructures(): void - { - /** @Given a collection with nested empty structures */ - $collection = Collection::createFrom(elements: [ - ['empty' => []], - ['nested' => ['deep' => []]], - [] - ]); - - /** @When converting to array */ - $actual = $collection->toArray(); - - /** @Then the nested structure should be preserved */ - self::assertSame([ - ['empty' => []], - ['nested' => ['deep' => []]], - [] - ], $actual); - } - - public function testAllEmptyItemsToJson(): void - { - /** @Given a collection where all items are empty */ - $collection = Collection::createFrom(elements: [[], '', null, 0, false]); - - /** @When converting to JSON */ - $actual = $collection->toJson(); - - /** @Then JSON should be a valid non-empty string */ - self::assertIsString($actual); - self::assertNotEmpty($actual); - } -} diff --git a/tests/ValueObjectMappingTest.php b/tests/ValueObjectMappingTest.php deleted file mode 100644 index 856d433..0000000 --- a/tests/ValueObjectMappingTest.php +++ /dev/null @@ -1,356 +0,0 @@ -toArray(); - - /** @Then all Value Objects should be unwrapped to their scalar values */ - self::assertSame($expected, $actual); - } - - #[DataProvider('dataProviderForValueObjectUnwrapping')] - public function testValueObjectsAreUnwrappedInJson(Organization $organization, array $expected): void - { - /** @Given an organization with deeply nested Value Objects */ - $expected = json_encode($expected, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE); - - /** @When converting the organization to JSON */ - $actual = $organization->toJson(); - - /** @Then all Value Objects should be unwrapped to their scalar values in JSON */ - self::assertJsonStringEqualsJsonString($expected, $actual); - } - - #[DataProvider('dataProviderForFromIterable')] - public function testCreateOrganizationFromIterable(array $data, Organization $expected): void - { - /** @Given an array with organization data containing Value Object structures */ - /** @When creating an organization from the iterable */ - $actual = Organization::fromIterable(iterable: $data); - - /** @Then the organization should be created with all Value Objects properly instantiated */ - self::assertEquals($expected->toArray(), $actual->toArray()); - } - - public function testSingleLevelUnwrapping(): void - { - /** @Given a Value Object with one level of nesting */ - $userId = new UserId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')); - - /** @When converting to array */ - $actual = $userId->toArray(); - - /** @Then the Value Object should be unwrapped to its scalar value */ - self::assertSame(['value' => '88f15d3f-c9b9-4855-9778-5ba7926b6736'], $actual); - } - - public function testDoubleLevelUnwrapping(): void - { - /** @Given a Value Object with two levels of nesting */ - $organizationId = new OrganizationId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')); - - /** @When converting to array */ - $actual = $organizationId->toArray(); - - /** @Then the Value Object should be unwrapped through both levels */ - self::assertSame(['value' => '88f15d3f-c9b9-4855-9778-5ba7926b6736'], $actual); - } - - public function testDeeplyNestedUnwrapping(): void - { - /** @Given a Value Object nested 15 levels deep */ - $current = new DeepValue(value: 'scalar-at-bottom'); - - for ($i = 0; $i < 14; $i++) { - $current = new DeepValue(value: $current); - } - - /** @When converting to array */ - $actual = $current->toArray(); - - /** @Then all levels should be unwrapped to the scalar */ - self::assertSame(['value' => 'scalar-at-bottom'], $actual); - } - - public function testValueObjectWrappingArray(): void - { - /** @Given a Value Object wrapping another Value Object whose value is an array */ - $inner = new DeepValue(value: ['a', 'b', 'c']); - $outer = new DeepValue(value: $inner); - - /** @When converting to array */ - $actual = $outer->toArray(); - - /** @Then both levels should be unwrapped to the array */ - self::assertSame(['value' => ['a', 'b', 'c']], $actual); - } - - public function testValueObjectWithNullValue(): void - { - /** @Given a Value Object whose value is null */ - $deepValue = new DeepValue(value: null); - - /** @When converting to array */ - $actual = $deepValue->toArray(); - - /** @Then null should be unwrapped from the Value Object */ - self::assertSame(['value' => null], $actual); - } - - public function testNestedValueObjectWithNullAtBottom(): void - { - /** @Given a nested Value Object with null at the deepest level */ - $inner = new DeepValue(value: null); - $outer = new DeepValue(value: $inner); - - /** @When converting to array */ - $actual = $outer->toArray(); - - /** @Then null should be unwrapped through all levels */ - self::assertSame(['value' => null], $actual); - } - - public function testComplexObjectWithMultipleValueObjects(): void - { - /** @Given a complex object with multiple deeply nested Value Objects */ - $member = new Member( - id: new MemberId(value: new Uuid(value: 'member-uuid')), - role: 'admin', - userId: new UserId(value: new Uuid(value: 'user-uuid')), - isOwner: true, - organizationId: new OrganizationId(value: new Uuid(value: 'org-uuid')) - ); - - /** @When converting to array */ - $actual = $member->toArray(); - - /** @Then all Value Objects should be unwrapped to scalars */ - self::assertSame([ - 'id' => 'member-uuid', - 'role' => 'admin', - 'userId' => 'user-uuid', - 'isOwner' => true, - 'organizationId' => 'org-uuid' - ], $actual); - } - - public function testCollectionWithValueObjects(): void - { - /** @Given a collection of members with nested Value Objects */ - $members = Members::createFrom(elements: [ - new Member( - id: new MemberId(value: new Uuid(value: '88f15d3f-c9b9-4855-9778-5ba7926b6736')), - role: 'admin', - userId: new UserId(value: new Uuid(value: '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8')), - isOwner: true, - organizationId: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a')) - ), - new Member( - id: new MemberId(value: new Uuid(value: 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6')), - role: 'viewer', - userId: new UserId(value: new Uuid(value: 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036')), - isOwner: false, - organizationId: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a')) - ) - ]); - - /** @When converting the collection to array */ - $actual = $members->toArray(); - - /** @Then all nested Value Objects should be unwrapped */ - self::assertSame([ - [ - 'id' => '88f15d3f-c9b9-4855-9778-5ba7926b6736', - 'role' => 'admin', - 'userId' => '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8', - 'isOwner' => true, - 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a' - ], - [ - 'id' => 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6', - 'role' => 'viewer', - 'userId' => 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036', - 'isOwner' => false, - 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a' - ] - ], $actual); - } - - public function testKeyPreservationWithValueObjects(): void - { - /** @Given an organization with Value Objects */ - $organization = new Organization( - id: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23')), - name: 'Test Org', - members: Members::createFromEmpty(), - invitations: [] - ); - - /** @When converting to array with PRESERVE keys */ - $actual = $organization->toArray(); - - /** @Then all keys should be preserved */ - self::assertArrayHasKey('id', $actual); - self::assertArrayHasKey('name', $actual); - self::assertArrayHasKey('members', $actual); - self::assertArrayHasKey('invitations', $actual); - } - - public static function dataProviderForValueObjectUnwrapping(): iterable - { - return [ - 'Organization with no members' => [ - 'organization' => new Organization( - id: new OrganizationId(value: new Uuid(value: 'empty-org')), - name: 'Empty Org', - members: Members::createFromEmpty(), - invitations: [] - ), - 'expected' => [ - 'id' => 'empty-org', - 'name' => 'Empty Org', - 'members' => [], - 'invitations' => [] - ] - ], - 'Organization with single member' => [ - 'organization' => new Organization( - id: new OrganizationId(value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b')), - name: 'Calenvo', - members: Members::createFrom(elements: [ - new Member( - id: new MemberId(value: new Uuid(value: '08a6ce33-95e7-43db-b566-9620216cdd5a')), - role: 'admin', - userId: new UserId(value: new Uuid(value: '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2')), - isOwner: true, - organizationId: new OrganizationId( - value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b') - ) - ) - ]), - invitations: [] - ), - 'expected' => [ - 'id' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b', - 'name' => 'Calenvo', - 'members' => [ - [ - 'id' => '08a6ce33-95e7-43db-b566-9620216cdd5a', - 'role' => 'admin', - 'userId' => '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2', - 'isOwner' => true, - 'organizationId' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b' - ] - ], - 'invitations' => [] - ] - ], - 'Organization with multiple members' => [ - 'organization' => new Organization( - 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', - userId: new UserId(value: new Uuid(value: '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8')), - 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', - userId: new UserId(value: new Uuid(value: 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036')), - 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', - 'userId' => '4a12fa11-33d1-4ac1-bc15-90af7dbee0c8', - 'isOwner' => true, - 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' - ], - [ - 'id' => 'c23b4c0a-f6d1-4b02-af2a-28b120a0ceb6', - 'role' => 'admin', - 'userId' => 'b2c98bc8-c3f2-451b-a476-c4ec6ae23036', - 'isOwner' => false, - 'organizationId' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23' - ] - ], - 'invitations' => [] - ] - ], - ]; - } - - public static function dataProviderForFromIterable(): iterable - { - return [ - 'Create organization from array with nested data' => [ - 'data' => [ - 'id' => ['value' => ['value' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b']], - 'name' => 'Calenvo', - 'members' => [ - [ - 'id' => ['value' => ['value' => '08a6ce33-95e7-43db-b566-9620216cdd5a']], - 'role' => 'admin', - 'userId' => ['value' => ['value' => '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2']], - 'isOwner' => true, - 'organizationId' => ['value' => ['value' => '6daca0fb-f718-414d-bdb8-5d1b2d65628b']] - ] - ], - 'invitations' => [] - ], - 'expected' => new Organization( - id: new OrganizationId(value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b')), - name: 'Calenvo', - members: Members::createFrom(elements: [ - new Member( - id: new MemberId(value: new Uuid(value: '08a6ce33-95e7-43db-b566-9620216cdd5a')), - role: 'admin', - userId: new UserId(value: new Uuid(value: '2e9f9b9b-febb-4c01-a7f7-f802c2e712d2')), - isOwner: true, - organizationId: new OrganizationId( - value: new Uuid(value: '6daca0fb-f718-414d-bdb8-5d1b2d65628b') - ) - ) - ]), - invitations: [] - ) - ] - ]; - } -} From 0049c43e3016eeedd6b429030435c1cae322c15a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 9 Feb 2026 20:30:08 -0300 Subject: [PATCH 3/4] feat: Removes unreachable code from internal classes, simplify value object unwrapping, and reorganize test suite. --- src/Internal/Detectors/ScalarDetector.php | 13 - .../Detectors/ValueObjectDetector.php | 14 +- src/Internal/Extractors/IterableExtractor.php | 48 +-- .../Extractors/ReflectionExtractor.php | 2 +- .../Factories/StrategyResolverFactory.php | 13 +- src/Internal/MappabilityBehavior.php | 25 ++ src/Internal/Mappers/JsonMapper.php | 8 +- .../Object/Casters/ArrayIteratorCaster.php | 15 - .../Mappers/Object/Casters/CasterHandler.php | 21 +- .../Mappers/Object/Casters/ClosureCaster.php | 15 - .../Mappers/Object/Casters/DateTimeCaster.php | 20 -- .../Mappers/Object/Casters/DefaultCaster.php | 4 +- .../Mappers/Object/Casters/EnumCaster.php | 6 +- .../Mappers/Object/Casters/ObjectMapper.php | 31 -- .../Mappers/Object/Casters/Reflector.php | 32 +- src/Internal/Resolvers/StrategyResolver.php | 20 +- .../ComplexObjectMappingStrategy.php | 18 +- .../Strategies/ConditionalMappingStrategy.php | 19 ++ .../Strategies/DateTimeMappingStrategy.php | 15 +- .../Strategies/EnumMappingStrategy.php | 15 +- .../Strategies/IterableMappingStrategy.php | 14 +- src/Internal/Strategies/MappingStrategy.php | 15 - .../Strategies/ScalarMappingStrategy.php | 32 -- src/IterableMappability.php | 19 +- src/ObjectMappability.php | 25 +- tests/EnumMappingTest.php | 89 ++---- tests/Models/Catalog.php | 17 ++ tests/Models/Configuration.php | 18 ++ tests/Models/Inventory.php | 18 ++ tests/Models/Priority.php | 12 + tests/Models/Task.php | 17 ++ tests/ObjectMappingTest.php | 276 ++++++++++++++---- 32 files changed, 420 insertions(+), 486 deletions(-) delete mode 100644 src/Internal/Detectors/ScalarDetector.php create mode 100644 src/Internal/MappabilityBehavior.php delete mode 100644 src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php delete mode 100644 src/Internal/Mappers/Object/Casters/ClosureCaster.php delete mode 100644 src/Internal/Mappers/Object/Casters/DateTimeCaster.php delete mode 100644 src/Internal/Mappers/Object/Casters/ObjectMapper.php create mode 100644 src/Internal/Strategies/ConditionalMappingStrategy.php delete mode 100644 src/Internal/Strategies/ScalarMappingStrategy.php create mode 100644 tests/Models/Catalog.php create mode 100644 tests/Models/Configuration.php create mode 100644 tests/Models/Inventory.php create mode 100644 tests/Models/Priority.php create mode 100644 tests/Models/Task.php diff --git a/src/Internal/Detectors/ScalarDetector.php b/src/Internal/Detectors/ScalarDetector.php deleted file mode 100644 index 03ecc7a..0000000 --- a/src/Internal/Detectors/ScalarDetector.php +++ /dev/null @@ -1,13 +0,0 @@ -getProperties(); + $properties = $reflection->getProperties( + ReflectionProperty::IS_PUBLIC + | ReflectionProperty::IS_PROTECTED + | ReflectionProperty::IS_PRIVATE + ); - return count($properties) === self::SINGLE_PROPERTY; + return !$value instanceof UnitEnum && count($properties) === self::SINGLE_PROPERTY; } } diff --git a/src/Internal/Extractors/IterableExtractor.php b/src/Internal/Extractors/IterableExtractor.php index 831e470..7ada306 100644 --- a/src/Internal/Extractors/IterableExtractor.php +++ b/src/Internal/Extractors/IterableExtractor.php @@ -4,7 +4,6 @@ namespace TinyBlocks\Mapper\Internal\Extractors; -use TinyBlocks\Mapper\IterableMapper; use Traversable; final readonly class IterableExtractor implements PropertyExtractor @@ -15,54 +14,15 @@ public function __construct(private ReflectionExtractor $extractor) public function extract(object $object): array { - if ($object instanceof IterableMapper) { - $iterable = $this->fromMapper(mapper: $object); - - if ($iterable !== null) { - return match (true) { - is_array($iterable) => $iterable, - $iterable instanceof Traversable => iterator_to_array($iterable), - }; - } - } - - if ($object instanceof Traversable) { - return iterator_to_array($object); - } - - return []; - } - - private function fromMapper(IterableMapper $mapper): ?iterable - { - if ($mapper instanceof Traversable) { - return $mapper; - } - - if (method_exists($mapper, 'getIterator')) { - return $mapper->getIterator(); - } - - return $this->fromProperties(mapper: $mapper); - } - - private function fromProperties(IterableMapper $mapper): ?iterable - { - $properties = $this->extractor->extractProperties(object: $mapper); - - $elements = $properties['elements'] ?? null; - - if (is_array($elements) || $elements instanceof Traversable) { - return $elements; - } + $properties = $this->extractor->extractProperties(object: $object); $candidates = array_filter( $properties, static fn(mixed $value): bool => is_array($value) || $value instanceof Traversable ); - return count($candidates) === 1 - ? reset($candidates) - : null; + $iterable = reset($candidates); + + return is_array($iterable) ? $iterable : iterator_to_array($iterable); } } diff --git a/src/Internal/Extractors/ReflectionExtractor.php b/src/Internal/Extractors/ReflectionExtractor.php index 5b74230..6ef2549 100644 --- a/src/Internal/Extractors/ReflectionExtractor.php +++ b/src/Internal/Extractors/ReflectionExtractor.php @@ -13,7 +13,7 @@ public function extractProperties(object $object): array { $reflection = new ReflectionClass(objectOrClass: $object); $properties = $reflection->getProperties( - filter: ReflectionProperty::IS_PUBLIC + ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE ); diff --git a/src/Internal/Factories/StrategyResolverFactory.php b/src/Internal/Factories/StrategyResolverFactory.php index d77573a..d7e6ec5 100644 --- a/src/Internal/Factories/StrategyResolverFactory.php +++ b/src/Internal/Factories/StrategyResolverFactory.php @@ -6,7 +6,6 @@ use TinyBlocks\Mapper\Internal\Detectors\DateTimeDetector; use TinyBlocks\Mapper\Internal\Detectors\EnumDetector; -use TinyBlocks\Mapper\Internal\Detectors\ScalarDetector; use TinyBlocks\Mapper\Internal\Detectors\ValueObjectDetector; use TinyBlocks\Mapper\Internal\Extractors\IterableExtractor; use TinyBlocks\Mapper\Internal\Extractors\ReflectionExtractor; @@ -18,7 +17,6 @@ use TinyBlocks\Mapper\Internal\Strategies\DateTimeMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\EnumMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\IterableMappingStrategy; -use TinyBlocks\Mapper\Internal\Strategies\ScalarMappingStrategy; use TinyBlocks\Mapper\Internal\Transformers\DateTimeTransformer; use TinyBlocks\Mapper\Internal\Transformers\EnumTransformer; use TinyBlocks\Mapper\Internal\Transformers\ValueObjectUnwrapper; @@ -43,12 +41,17 @@ public function create(): StrategyResolver valueObjectDetector: $valueObjectDetector ); + $default = new ComplexObjectMappingStrategy( + extractor: $reflectionExtractor, + resolver: $recursiveValueResolver + ); + $resolver = new StrategyResolver( + $default, new EnumMappingStrategy( detector: new EnumDetector(), transformer: new EnumTransformer() ), - new ScalarMappingStrategy(detector: new ScalarDetector()), new DateTimeMappingStrategy( detector: new DateTimeDetector(), transformer: new DateTimeTransformer() @@ -56,10 +59,6 @@ public function create(): StrategyResolver new IterableMappingStrategy( extractor: new IterableExtractor(extractor: $reflectionExtractor), resolver: $recursiveValueResolver - ), - new ComplexObjectMappingStrategy( - extractor: $reflectionExtractor, - resolver: $recursiveValueResolver ) ); 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/JsonMapper.php b/src/Internal/Mappers/JsonMapper.php index 8559c08..e8d1a78 100644 --- a/src/Internal/Mappers/JsonMapper.php +++ b/src/Internal/Mappers/JsonMapper.php @@ -4,12 +4,14 @@ namespace TinyBlocks\Mapper\Internal\Mappers; -final class JsonMapper +final readonly class JsonMapper { - private const int JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION; + private const int JSON_FLAGS = JSON_THROW_ON_ERROR + | JSON_UNESCAPED_UNICODE + | JSON_PRESERVE_ZERO_FRACTION; public function map(array $value): string { - return (string)json_encode($value, self::JSON_FLAGS); + return json_encode($value, self::JSON_FLAGS); } } diff --git a/src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php b/src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php deleted file mode 100644 index 923b48e..0000000 --- a/src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php +++ /dev/null @@ -1,15 +0,0 @@ -parameter->getType()->getName(); - $caster = $this->resolveCaster(typeName: $typeName); + $caster = match (true) { + $typeName === Generator::class => new GeneratorCaster(), + enum_exists($typeName) => new EnumCaster(class: $typeName), + default => new DefaultCaster(class: $typeName) + }; return $caster->castValue(value: $value); } - - protected function resolveCaster(string $typeName): Caster - { - return match (true) { - $typeName === Closure::class => new ClosureCaster(), - $typeName === Generator::class => new GeneratorCaster(), - $typeName === ArrayIterator::class => new ArrayIteratorCaster(), - $typeName === DateTimeImmutable::class => new DateTimeCaster(), - enum_exists($typeName) => new EnumCaster(class: $typeName), - default => new DefaultCaster(class: $typeName) - }; - } } diff --git a/src/Internal/Mappers/Object/Casters/ClosureCaster.php b/src/Internal/Mappers/Object/Casters/ClosureCaster.php deleted file mode 100644 index b9d4e7b..0000000 --- a/src/Internal/Mappers/Object/Casters/ClosureCaster.php +++ /dev/null @@ -1,15 +0,0 @@ - $value; - } -} diff --git a/src/Internal/Mappers/Object/Casters/DateTimeCaster.php b/src/Internal/Mappers/Object/Casters/DateTimeCaster.php deleted file mode 100644 index 2694bcf..0000000 --- a/src/Internal/Mappers/Object/Casters/DateTimeCaster.php +++ /dev/null @@ -1,20 +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 ef74a29..aa55627 100644 --- a/src/Internal/Mappers/Object/Casters/EnumCaster.php +++ b/src/Internal/Mappers/Object/Casters/EnumCaster.php @@ -11,12 +11,16 @@ final readonly class EnumCaster implements Caster { - public function __construct(public string $class) + public function __construct(private string $class) { } public function castValue(mixed $value): UnitEnum { + if ($value instanceof $this->class) { + return $value; + } + $reflection = new ReflectionEnum(objectOrClass: $this->class); foreach ($reflection->getCases() as $case) { diff --git a/src/Internal/Mappers/Object/Casters/ObjectMapper.php b/src/Internal/Mappers/Object/Casters/ObjectMapper.php deleted file mode 100644 index 005133a..0000000 --- a/src/Internal/Mappers/Object/Casters/ObjectMapper.php +++ /dev/null @@ -1,31 +0,0 @@ -getParameters(); - $inputProperties = iterator_to_array($iterable); - $arguments = []; - - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - $value = $inputProperties[$name] ?? null; - - if ($value !== null) { - $caster = new CasterHandler(parameter: $parameter); - $arguments[] = $caster->castValue(value: $value); - continue; - } - - $arguments[] = $parameter->getDefaultValue(); - } - - return $reflector->newInstance(constructorArguments: $arguments); - } -} diff --git a/src/Internal/Mappers/Object/Casters/Reflector.php b/src/Internal/Mappers/Object/Casters/Reflector.php index c33d5e6..813fa60 100644 --- a/src/Internal/Mappers/Object/Casters/Reflector.php +++ b/src/Internal/Mappers/Object/Casters/Reflector.php @@ -5,46 +5,20 @@ namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use ReflectionClass; -use ReflectionMethod; final readonly class Reflector { - private ?ReflectionMethod $constructor; - private array $parameters; - - public function __construct(private ReflectionClass $reflectionClass) + private function __construct(private ReflectionClass $reflectionClass) { - $this->constructor = $reflectionClass->getConstructor(); - $this->parameters = $this->constructor instanceof ReflectionMethod - ? $this->constructor->getParameters() - : []; } public static function reflectFrom(string $class): Reflector { - return new Reflector(reflectionClass: new ReflectionClass(objectOrClass: $class)); - } - - public function getParameters(): array - { - return $this->parameters; + return new Reflector(reflectionClass: new ReflectionClass($class)); } public function newInstance(array $constructorArguments): object { - $instance = $this->constructor instanceof ReflectionMethod && $this->constructor->isPrivate() - ? $this->newInstanceWithoutConstructor() - : $this->reflectionClass->newInstanceArgs(args: $constructorArguments); - - if ($this->constructor instanceof ReflectionMethod && $this->constructor->isPrivate()) { - $this->constructor->invokeArgs(object: $instance, args: $constructorArguments); - } - - return $instance; - } - - public function newInstanceWithoutConstructor(): object - { - return $this->reflectionClass->newInstanceWithoutConstructor(); + return $this->reflectionClass->newInstanceArgs($constructorArguments); } } diff --git a/src/Internal/Resolvers/StrategyResolver.php b/src/Internal/Resolvers/StrategyResolver.php index 1a31fc0..0218c06 100644 --- a/src/Internal/Resolvers/StrategyResolver.php +++ b/src/Internal/Resolvers/StrategyResolver.php @@ -4,15 +4,16 @@ namespace TinyBlocks\Mapper\Internal\Resolvers; +use TinyBlocks\Mapper\Internal\Strategies\ConditionalMappingStrategy; use TinyBlocks\Mapper\Internal\Strategies\MappingStrategy; final class StrategyResolver { private array $strategies; - public function __construct(MappingStrategy ...$strategies) + public function __construct(private readonly MappingStrategy $default, ConditionalMappingStrategy ...$strategies) { - $this->strategies = $this->sortByPriority(strategies: $strategies); + $this->strategies = $strategies; } public function resolve(mixed $value): MappingStrategy @@ -23,19 +24,6 @@ public function resolve(mixed $value): MappingStrategy } } - return end($this->strategies); - } - - private function sortByPriority(array $strategies): array - { - usort( - $strategies, - static fn( - MappingStrategy $current, - MappingStrategy $next - ): int => $next->priority() <=> $current->priority() - ); - - return $strategies; + return $this->default; } } diff --git a/src/Internal/Strategies/ComplexObjectMappingStrategy.php b/src/Internal/Strategies/ComplexObjectMappingStrategy.php index d3d7a38..9b448ab 100644 --- a/src/Internal/Strategies/ComplexObjectMappingStrategy.php +++ b/src/Internal/Strategies/ComplexObjectMappingStrategy.php @@ -10,12 +10,8 @@ final readonly class ComplexObjectMappingStrategy implements MappingStrategy { - private const int PRIORITY = 50; - - public function __construct( - private ReflectionExtractor $extractor, - private RecursiveValueResolver $resolver - ) { + public function __construct(private ReflectionExtractor $extractor, private RecursiveValueResolver $resolver) + { } public function map(mixed $value, KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array @@ -34,14 +30,4 @@ public function map(mixed $value, KeyPreservation $keyPreservation = KeyPreserva ? $mapped : array_values($mapped); } - - public function supports(mixed $value): bool - { - return is_object(value: $value); - } - - public function priority(): int - { - return self::PRIORITY; - } } 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 @@ +detector->matches(value: $value); } - - public function priority(): int - { - return self::PRIORITY; - } } diff --git a/src/Internal/Strategies/EnumMappingStrategy.php b/src/Internal/Strategies/EnumMappingStrategy.php index 699d6b1..d64ffea 100644 --- a/src/Internal/Strategies/EnumMappingStrategy.php +++ b/src/Internal/Strategies/EnumMappingStrategy.php @@ -8,14 +8,10 @@ use TinyBlocks\Mapper\Internal\Transformers\EnumTransformer; use TinyBlocks\Mapper\KeyPreservation; -final readonly class EnumMappingStrategy implements MappingStrategy +final readonly class EnumMappingStrategy implements ConditionalMappingStrategy { - private const int PRIORITY = 80; - - public function __construct( - private EnumDetector $detector, - private EnumTransformer $transformer - ) { + public function __construct(private EnumDetector $detector, private EnumTransformer $transformer) + { } public function map(mixed $value, KeyPreservation $keyPreservation): string|int @@ -27,9 +23,4 @@ public function supports(mixed $value): bool { return $this->detector->matches(value: $value); } - - public function priority(): int - { - return self::PRIORITY; - } } diff --git a/src/Internal/Strategies/IterableMappingStrategy.php b/src/Internal/Strategies/IterableMappingStrategy.php index b692229..19a69c6 100644 --- a/src/Internal/Strategies/IterableMappingStrategy.php +++ b/src/Internal/Strategies/IterableMappingStrategy.php @@ -8,12 +8,9 @@ use TinyBlocks\Mapper\Internal\Resolvers\RecursiveValueResolver; use TinyBlocks\Mapper\IterableMapper; use TinyBlocks\Mapper\KeyPreservation; -use Traversable; -final readonly class IterableMappingStrategy implements MappingStrategy +final readonly class IterableMappingStrategy implements ConditionalMappingStrategy { - private const int PRIORITY = 60; - public function __construct(private IterableExtractor $extractor, private RecursiveValueResolver $resolver) { } @@ -32,13 +29,6 @@ public function map(mixed $value, KeyPreservation $keyPreservation): array public function supports(mixed $value): bool { - return is_array($value) - || $value instanceof Traversable - || $value instanceof IterableMapper; - } - - public function priority(): int - { - return self::PRIORITY; + return $value instanceof IterableMapper; } } diff --git a/src/Internal/Strategies/MappingStrategy.php b/src/Internal/Strategies/MappingStrategy.php index 475ecab..45bff52 100644 --- a/src/Internal/Strategies/MappingStrategy.php +++ b/src/Internal/Strategies/MappingStrategy.php @@ -19,19 +19,4 @@ interface MappingStrategy * @return mixed The mapped value. */ public function map(mixed $value, KeyPreservation $keyPreservation): mixed; - - /** - * Checks if the strategy supports the given value. - * - * @param mixed $value The value to check. - * @return bool True if supported, false otherwise. - */ - public function supports(mixed $value): bool; - - /** - * Returns the priority of this strategy (higher = checked first). - * - * @return int The priority value. - */ - public function priority(): int; } diff --git a/src/Internal/Strategies/ScalarMappingStrategy.php b/src/Internal/Strategies/ScalarMappingStrategy.php deleted file mode 100644 index 14d2755..0000000 --- a/src/Internal/Strategies/ScalarMappingStrategy.php +++ /dev/null @@ -1,32 +0,0 @@ -detector->matches(value: $value); - } - - public function priority(): int - { - return self::PRIORITY; - } -} diff --git a/src/IterableMappability.php b/src/IterableMappability.php index 2eb8d95..06cc31f 100644 --- a/src/IterableMappability.php +++ b/src/IterableMappability.php @@ -4,26 +4,11 @@ namespace TinyBlocks\Mapper; -use TinyBlocks\Mapper\Internal\Factories\StrategyResolverFactory; -use TinyBlocks\Mapper\Internal\Mappers\ArrayMapper; -use TinyBlocks\Mapper\Internal\Mappers\JsonMapper; +use TinyBlocks\Mapper\Internal\MappabilityBehavior; trait IterableMappability { - public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string - { - $jsonMapper = new JsonMapper(); - return $jsonMapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); - } - - public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array - { - $factory = new StrategyResolverFactory(); - $resolver = $factory->create(); - $arrayMapper = new ArrayMapper(resolver: $resolver); - - return $arrayMapper->map(value: $this, keyPreservation: $keyPreservation); - } + use MappabilityBehavior; public function getType(): string { diff --git a/src/ObjectMappability.php b/src/ObjectMappability.php index 644ccd1..ce50b99 100644 --- a/src/ObjectMappability.php +++ b/src/ObjectMappability.php @@ -6,33 +6,16 @@ use TinyBlocks\Mapper\Internal\Builders\ObjectBuilder; use TinyBlocks\Mapper\Internal\Extractors\ReflectionExtractor; -use TinyBlocks\Mapper\Internal\Factories\StrategyResolverFactory; -use TinyBlocks\Mapper\Internal\Mappers\ArrayMapper; -use TinyBlocks\Mapper\Internal\Mappers\JsonMapper; +use TinyBlocks\Mapper\Internal\MappabilityBehavior; trait ObjectMappability { + use MappabilityBehavior; + public static function fromIterable(iterable $iterable): static { $extractor = new ReflectionExtractor(); - $builder = new ObjectBuilder(extractor: $extractor); - - /** @var static */ - return $builder->build(iterable: $iterable, class: static::class); - } - - public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string - { - $jsonMapper = new JsonMapper(); - return $jsonMapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); - } - - public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array - { - $factory = new StrategyResolverFactory(); - $resolver = $factory->create(); - $arrayMapper = new ArrayMapper(resolver: $resolver); - return $arrayMapper->map(value: $this, keyPreservation: $keyPreservation); + return new ObjectBuilder(extractor: $extractor)->build(iterable: $iterable, class: static::class); } } diff --git a/tests/EnumMappingTest.php b/tests/EnumMappingTest.php index bfae255..b92c4e9 100644 --- a/tests/EnumMappingTest.php +++ b/tests/EnumMappingTest.php @@ -6,23 +6,23 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\Mapper\Models\Amount; -use Test\TinyBlocks\Mapper\Models\Currency; use Test\TinyBlocks\Mapper\Models\Dragon; use Test\TinyBlocks\Mapper\Models\DragonSkills; use Test\TinyBlocks\Mapper\Models\DragonType; +use Test\TinyBlocks\Mapper\Models\Task; use TinyBlocks\Mapper\Internal\Exceptions\InvalidCast; final class EnumMappingTest extends TestCase { - public function testPureEnum(): void + public function testEnum(): void { /** @Given a Dragon with a pure enum type */ - $dragon = new Dragon( - name: 'Smaug', - type: DragonType::FIRE, - power: 9999.99, - skills: [] - ); + $dragon = Dragon::fromIterable(iterable: [ + 'name' => 'Smaug', + 'type' => 'FIRE', + 'power' => 9999.99, + 'skills' => [] + ]); /** @When mapping the Dragon to an array */ $actual = $dragon->toArray(); @@ -41,27 +41,30 @@ public function testPureEnum(): void self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); } - public function testBackedStringEnum(): void + public function testEnumBackedInt(): void { - /** @Given an Amount with a backed string enum */ - $amount = Amount::from(value: 100.00, currency: Currency::BRL); + /** @Given a Task with an array of backed int enums */ + $task = Task::fromIterable(iterable: [ + 'title' => 'Fix bug', + 'priority' => 3 + ]); - /** @When mapping the Amount to an array */ - $actual = $amount->toArray(); + /** @When mapping the Task to an array */ + $actual = $task->toArray(); /** @Then the mapped array should have expected values */ $expected = [ - 'value' => 100.00, - 'currency' => 'BRL' + '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), $amount->toJson()); + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $task->toJson()); } - public function testBackedStringEnumArray(): void + public function testEnumBackedString(): void { /** @Given a Dragon with an array of backed string enums */ $dragon = new Dragon( @@ -92,57 +95,7 @@ public function testBackedStringEnumArray(): void self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); } - public function testBackedEnumFromIterable(): void - { - /** @Given an Amount created from iterable data with a backed enum value */ - $amount = Amount::fromIterable(iterable: [ - 'value' => 500.00, - 'currency' => 'USD' - ]); - - /** @When mapping the Amount to an array */ - $actual = $amount->toArray(); - - /** @Then the mapped array should have expected values */ - $expected = [ - 'value' => 500.00, - 'currency' => 'USD' - ]; - - self::assertSame($expected, $actual); - - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $amount->toJson()); - } - - public function testEnumArrayFromIterable(): void - { - /** @Given a Dragon created from iterable data with enum values as strings */ - $dragon = Dragon::fromIterable(iterable: [ - 'name' => 'Bahamut', - 'type' => 'FIRE', - 'power' => 15000.00, - 'skills' => ['fly', 'spell', 'elemental_breath'] - ]); - - /** @When mapping the Dragon to an array */ - $actual = $dragon->toArray(); - - /** @Then the mapped array should have expected values */ - $expected = [ - 'name' => 'Bahamut', - 'type' => 'FIRE', - 'power' => 15000.00, - 'skills' => ['fly', 'spell', 'elemental_breath'] - ]; - - self::assertSame($expected, $actual); - - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $dragon->toJson()); - } - - public function testInvalidEnumValueThrowsException(): void + public function testEnumWhenInvalidCast(): void { /** @Given data with an invalid currency value */ $data = [ 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 @@ +toArray(); /** @Then the mapped array should have expected values */ self::assertSame($expected, $actual); - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $object->toJson()); - } - - public function testObjectWithClosure(): void - { - /** @Given a Service with a Closure property */ - $service = new Service(action: static fn() => 'executed'); - - /** @When mapping the Service to an array */ - $actual = $service->toArray(); + /** @And when mapping the object to JSON */ + $actual = $object->toJson(); - /** @Then the mapped array should have expected values */ - $expected = ['action' => []]; - - self::assertSame($expected, $actual); - - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $service->toJson()); + /** @Then the mapped JSON should have expected values */ + self::assertJsonStringEqualsJsonString((string)json_encode($expected), $actual); } public function testObjectWithGenerator(): void @@ -90,8 +83,29 @@ public function testObjectWithGenerator(): void self::assertSame($expected, $actual); - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), json_encode($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 @@ -112,8 +126,11 @@ public function testObjectWithoutConstructor(): void self::assertSame($expected, $actual); - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $webhook->toJson()); + /** @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 @@ -135,8 +152,91 @@ public function testObjectWithStaticProperties(): void self::assertSame($expected, $actual); - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $webhook->toJson()); + /** @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 @@ -155,57 +255,91 @@ public function testObjectWithoutConstructorWithDefaultValues(): void self::assertSame($expected, $actual); - /** @And the JSON representation should be the mapped JSON object */ - self::assertJsonStringEqualsJsonString((string)json_encode($expected), $webhook->toJson()); + /** @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' => [ - 'object' => new Tag(), - 'expected' => ['name' => '', 'color' => 'gray'] + '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' => [ - 'object' => Service::fromIterable(iterable: ['action' => static fn() => 'executed']), + 'class' => Service::class, + 'iterable' => ['action' => static fn() => 'executed'], 'expected' => ['action' => []] ], 'Product object' => [ - 'object' => new Product( - id: 1, - amount: Amount::from(value: 49.90, currency: Currency::USD), - description: new Description(text: 'Wireless Mouse'), - attributes: new ArrayIterator([ - 'color' => Color::BLUE, - 'weight' => 0.12, - 'wireless' => true + '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: [10, 25, 50], - status: ProductStatus::ACTIVE, - createdAt: new DateTimeImmutable('2026-01-15T08:30:00+00:00') - ), + 'inventory' => ['stock' => 100, 'warehouse' => 'A1'], + 'status' => ProductStatus::ACTIVE, + 'createdAt' => new DateTimeImmutable('2026-01-01T10:00:00+00:00') + ], 'expected' => [ 'id' => 1, - 'amount' => [ - 'value' => 49.90, - 'currency' => 'USD' - ], - 'description' => 'Wireless Mouse', - 'attributes' => [ - 'color' => 'blue', - 'weight' => 0.12, - 'wireless' => true - ], - 'inventory' => [10, 25, 50], + '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-15T08:30:00+00:00' - ] + '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' => [ - 'object' => new Organization( - id: new OrganizationId(value: new Uuid(value: 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23')), - name: 'Tech Corp', - members: Members::createFrom(elements: [ + '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', @@ -223,8 +357,8 @@ public static function objectProvider(): array ) ) ]), - invitations: [] - ), + 'invitations' => [] + ], 'expected' => [ 'id' => 'dc0dbdfd-9f8d-43c9-a000-19bcc989d20a23', 'name' => 'Tech Corp', @@ -247,4 +381,22 @@ public static function objectProvider(): array ] ]; } + + 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] + ] + ]; + } } From 9dd42a9ff61bb609ebf4f12854259d84e4879631 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 9 Feb 2026 20:38:58 -0300 Subject: [PATCH 4/4] feat: Removes unreachable code from internal classes, simplify value object unwrapping, and reorganize test suite. --- phpstan.neon.dist | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ef35c24..f699dd9 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,11 +6,9 @@ parameters: ignoreErrors: - '#T of#' - '#mixed#' - - '#template type#' - - '#ReflectionType#' - - '#generic interface#' + - '#UnitEnum#' + - '#Reflection#' + - '#Traversable#' - '#is used zero times#' - - '#expects class-string#' - - '#value type specified#' - - '#not specify its types: T#' + - '#type specified in iterable type#' reportUnmatchedIgnoredErrors: false