From 1a380608ee8e1bb7e462cf1a20c9d039cc8c5cb9 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 24 Dec 2024 09:33:35 -0300 Subject: [PATCH] Implements Mapper library for flexible data transformations. (#1) * feat: Implements Mapper library for flexible data transformations. --- .gitattributes | 14 + .github/workflows/auto-assign.yml | 25 + .github/workflows/ci.yml | 49 ++ .gitignore | 6 + Makefile | 38 ++ README.md | 177 +++++- composer.json | 76 +++ infection.json.dist | 24 + phpmd.xml | 61 ++ phpstan.neon.dist | 12 + phpunit.xml | 37 ++ src/Internal/Exceptions/InvalidCast.php | 17 + .../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 + .../Mappers/Object/Casters/CastingHandler.php | 39 ++ .../Mappers/Object/Casters/TypeCaster.php | 22 + .../Casters/Types/ArrayIteratorCaster.php | 16 + .../Object/Casters/Types/DateTimeCaster.php | 16 + .../Object/Casters/Types/DefaultCaster.php | 48 ++ .../Object/Casters/Types/EnumCaster.php | 36 ++ .../Object/Casters/Types/GeneratorCaster.php | 24 + src/Internal/Mappers/Object/ObjectMapper.php | 31 + src/IterableMappability.php | 25 + src/IterableMapper.php | 12 + src/KeyPreservation.php | 31 + src/Mapper.php | 43 ++ src/ObjectMappability.php | 33 ++ src/ObjectMapper.php | 23 + tests/Models/Amount.php | 22 + tests/Models/Configuration.php | 18 + tests/Models/Currency.php | 11 + tests/Models/Decimal.php | 23 + tests/Models/Dragon.php | 21 + tests/Models/DragonSkills.php | 13 + tests/Models/DragonType.php | 10 + tests/Models/ExpirationDate.php | 18 + tests/Models/InvoiceSummaries.php | 11 + tests/Models/InvoiceSummary.php | 12 + tests/Models/Order.php | 19 + tests/Models/Product.php | 18 + tests/Models/Service.php | 18 + tests/Models/Shipping.php | 17 + tests/Models/ShippingAddress.php | 22 + tests/Models/ShippingAddresses.php | 31 + tests/Models/ShippingCountry.php | 11 + tests/Models/ShippingState.php | 11 + tests/ObjectMapperTest.php | 550 ++++++++++++++++++ 51 files changed, 1952 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/auto-assign.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 composer.json create mode 100644 infection.json.dist create mode 100644 phpmd.xml create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml create mode 100644 src/Internal/Exceptions/InvalidCast.php create mode 100644 src/Internal/Mappers/Collection/ArrayMapper.php create mode 100644 src/Internal/Mappers/Collection/CollectionMapper.php create mode 100644 src/Internal/Mappers/Collection/DateTimeMapper.php create mode 100644 src/Internal/Mappers/Collection/EnumMapper.php create mode 100644 src/Internal/Mappers/Collection/ValueMapper.php create mode 100644 src/Internal/Mappers/Json/JsonMapper.php create mode 100644 src/Internal/Mappers/Object/Casters/CastingHandler.php create mode 100644 src/Internal/Mappers/Object/Casters/TypeCaster.php create mode 100644 src/Internal/Mappers/Object/Casters/Types/ArrayIteratorCaster.php create mode 100644 src/Internal/Mappers/Object/Casters/Types/DateTimeCaster.php create mode 100644 src/Internal/Mappers/Object/Casters/Types/DefaultCaster.php create mode 100644 src/Internal/Mappers/Object/Casters/Types/EnumCaster.php create mode 100644 src/Internal/Mappers/Object/Casters/Types/GeneratorCaster.php create mode 100644 src/Internal/Mappers/Object/ObjectMapper.php create mode 100644 src/IterableMappability.php create mode 100644 src/IterableMapper.php create mode 100644 src/KeyPreservation.php create mode 100644 src/Mapper.php create mode 100644 src/ObjectMappability.php create mode 100644 src/ObjectMapper.php create mode 100644 tests/Models/Amount.php create mode 100644 tests/Models/Configuration.php create mode 100644 tests/Models/Currency.php create mode 100644 tests/Models/Decimal.php create mode 100644 tests/Models/Dragon.php create mode 100644 tests/Models/DragonSkills.php create mode 100644 tests/Models/DragonType.php create mode 100644 tests/Models/ExpirationDate.php create mode 100644 tests/Models/InvoiceSummaries.php create mode 100644 tests/Models/InvoiceSummary.php create mode 100644 tests/Models/Order.php create mode 100644 tests/Models/Product.php create mode 100644 tests/Models/Service.php create mode 100644 tests/Models/Shipping.php create mode 100644 tests/Models/ShippingAddress.php create mode 100644 tests/Models/ShippingAddresses.php create mode 100644 tests/Models/ShippingCountry.php create mode 100644 tests/Models/ShippingState.php create mode 100644 tests/ObjectMapperTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4126d6c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/tests export-ignore +/vendor export-ignore + +/README.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/phpmd.xml export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..b1c4e50 --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto assign issues and pull requests + +on: + issues: + types: + - opened + pull_request: + types: + - opened + +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Assign issues and pull requests + uses: gustavofreze/auto-assign@1.1.4 + with: + assignees: '${{ secrets.ASSIGNEES }}' + github_token: '${{ secrets.GITHUB_TOKEN }}' + allow_self_assign: 'true' + allow_no_assignees: 'true' + assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..358ac92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +env: + PHP_VERSION: '8.3' + +jobs: + auto-review: + name: Auto review + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + + - name: Install dependencies + run: composer update --no-progress --optimize-autoloader + + - name: Run review + run: composer review + + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use PHP ${{ env.PHP_VERSION }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + + - name: Install dependencies + run: composer update --no-progress --optimize-autoloader + + - name: Run tests + run: composer tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0593f2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.lock +.phpunit.* +.idea/ + +/vendor/ +/report \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..81f8617 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +ifeq ($(OS),Windows_NT) + PWD := $(shell cd) +else + PWD := $(shell pwd -L) +endif + +ARCH := $(shell uname -m) +PLATFORM := + +ifeq ($(ARCH),arm64) + PLATFORM := --platform=linux/amd64 +endif + +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.3 + +.PHONY: configure test test-file test-no-coverage review show-reports clean + +configure: + @${DOCKER_RUN} composer update --optimize-autoloader + +test: + @${DOCKER_RUN} composer tests + +test-file: + @${DOCKER_RUN} composer test-file ${FILE} + +test-no-coverage: + @${DOCKER_RUN} composer tests-no-coverage + +review: + @${DOCKER_RUN} composer review + +show-reports: + @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html + +clean: + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf report vendor .phpunit.cache .lock diff --git a/README.md b/README.md index f01f423..216eb18 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,175 @@ -# mapper -Allows mapping data between different formats, such as JSON, arrays, and DTOs, providing flexibility in transforming and serializing information. +# Mapper + +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) +* [License](#license) +* [Contributing](#contributing) + +
+ +## Overview + +Allows mapping data between different formats, such as JSON, arrays, and DTOs, providing flexibility in transforming and +serializing information. + +
+ +## Installation + +```bash +composer require tiny-blocks/mapper +``` + +
+ +## How to use + +### Object + +The library exposes available behaviors through the `ObjectMapper` interface, and the implementation of these behaviors +through the `ObjectMappability` trait. + +#### Create an object from an iterable + +You can map data from an iterable (such as an array) into an object. Here's how to map a `Shipping` object from an +iterable: + +```php +elements = is_array($elements) ? $elements : iterator_to_array($elements); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->elements); + } +} + +``` + +Now you can map data into a `Shipping` object using `fromIterable`: + +```php + PHP_INT_MAX, + 'addresses' => [ + [ + 'city' => 'New York', + 'state' => 'NY', + 'street' => '5th Avenue', + 'number' => 717, + 'country' => 'US' + ] + ] +]); +``` + +#### Map object to array + +Once the object is created, you can easily convert it into an array representation. + +```php +$shipping->toArray(); +``` + +This will output the following array: + +```php +[ + 'id' => 9223372036854775807, + 'addresses' => [ + [ + 'city' => 'New York', + 'state' => 'NY', + 'street' => '5th Avenue', + 'number' => 717, + 'country' => 'US' + ] + ] +] +``` + +#### Map object to JSON + +Similarly, you can convert the object into a JSON representation. + +```php +$shipping->toJson(); +``` + +This will produce the following JSON: + +```json +{ + "id": 9223372036854775807, + "addresses": [ + { + "city": "New York", + "state": "NY", + "street": "5th Avenue", + "number": 717, + "country": "US" + } + ] +} +``` + +
+ +## License + +Mapper is licensed under [MIT](LICENSE). + +
+ +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1cb043c --- /dev/null +++ b/composer.json @@ -0,0 +1,76 @@ +{ + "name": "tiny-blocks/mapper", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/tiny-blocks/mapper", + "description": "Allows mapping data between different formats, such as JSON, arrays, and DTOs, providing flexibility in transforming and serializing information.", + "prefer-stable": true, + "minimum-stability": "stable", + "keywords": [ + "psr", + "dto", + "json", + "array", + "object", + "mapper", + "tiny-blocks" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "support": { + "issues": "https://github.com/tiny-blocks/mapper/issues", + "source": "https://github.com/tiny-blocks/mapper" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "autoload": { + "psr-4": { + "TinyBlocks\\Mapper\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "TinyBlocks\\Mapper\\": "tests/" + } + }, + "require": { + "php": "^8.3", + "tiny-blocks/collection": "^1.8" + }, + "require-dev": { + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1", + "phpunit/phpunit": "^11", + "infection/infection": "^0", + "squizlabs/php_codesniffer": "^3.11" + }, + "scripts": { + "test": "phpunit --configuration phpunit.xml tests", + "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", + "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit", + "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "test-file": "phpunit --configuration phpunit.xml --no-coverage --filter", + "mutation-test": "infection --only-covered --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", + "test-no-coverage": "phpunit --configuration phpunit.xml --no-coverage tests", + "review": [ + "@phpcs", + "@phpmd", + "@phpstan" + ], + "tests": [ + "@test", + "@mutation-test" + ], + "tests-no-coverage": [ + "@test-no-coverage" + ] + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..cd91b3c --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,24 @@ +{ + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, + "tmpDir": "report/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true, + "PublicVisibility": false + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 0000000..be1b346 --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,61 @@ + + + PHPMD Custom rules + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..d081be5 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,12 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + - '#method#' + - '#expects#' + - '#should return#' + - '#not specify its types#' + - '#no value type specified#' + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..40c80a2 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + diff --git a/src/Internal/Exceptions/InvalidCast.php b/src/Internal/Exceptions/InvalidCast.php new file mode 100644 index 0000000..0cff8b5 --- /dev/null +++ b/src/Internal/Exceptions/InvalidCast.php @@ -0,0 +1,17 @@ + for enum <%s>.', $value, $class); + + return new InvalidCast(message: $message); + } +} diff --git a/src/Internal/Mappers/Collection/ArrayMapper.php b/src/Internal/Mappers/Collection/ArrayMapper.php new file mode 100644 index 0000000..a8bc1e7 --- /dev/null +++ b/src/Internal/Mappers/Collection/ArrayMapper.php @@ -0,0 +1,48 @@ +valueIsCollectible(value: $value)) { + $collectionMapper = new CollectionMapper(valueMapper: $valueMapper); + $mappedValues = $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 new file mode 100644 index 0000000..c5f1a44 --- /dev/null +++ b/src/Internal/Mappers/Collection/CollectionMapper.php @@ -0,0 +1,26 @@ + $element) { + $mappedValues[$key] = $this->valueMapper->map(value: $element, keyPreservation: $keyPreservation); + } + + return $mappedValues; + } +} diff --git a/src/Internal/Mappers/Collection/DateTimeMapper.php b/src/Internal/Mappers/Collection/DateTimeMapper.php new file mode 100644 index 0000000..c479d8a --- /dev/null +++ b/src/Internal/Mappers/Collection/DateTimeMapper.php @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..913a906 --- /dev/null +++ b/src/Internal/Mappers/Collection/EnumMapper.php @@ -0,0 +1,16 @@ +value : $value->name; + } +} diff --git a/src/Internal/Mappers/Collection/ValueMapper.php b/src/Internal/Mappers/Collection/ValueMapper.php new file mode 100644 index 0000000..d47d11f --- /dev/null +++ b/src/Internal/Mappers/Collection/ValueMapper.php @@ -0,0 +1,31 @@ + (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 new file mode 100644 index 0000000..b2b9f2c --- /dev/null +++ b/src/Internal/Mappers/Json/JsonMapper.php @@ -0,0 +1,21 @@ + $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/Object/Casters/CastingHandler.php b/src/Internal/Mappers/Object/Casters/CastingHandler.php new file mode 100644 index 0000000..0efc27a --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/CastingHandler.php @@ -0,0 +1,39 @@ +class = $this->targetProperty->getType()->getName(); + } + + public function applyCast(): mixed + { + $caster = match (true) { + $this->class === Generator::class, => new GeneratorCaster(), + $this->class === ArrayIterator::class, => new ArrayIteratorCaster(), + $this->class === DateTimeImmutable::class, => new DateTimeCaster(), + is_subclass_of($this->class, UnitEnum::class) => new EnumCaster(class: $this->class), + default => new DefaultCaster(property: $this->targetProperty) + }; + + return $caster->applyCast(value: $this->value); + } +} diff --git a/src/Internal/Mappers/Object/Casters/TypeCaster.php b/src/Internal/Mappers/Object/Casters/TypeCaster.php new file mode 100644 index 0000000..1c6cde7 --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/TypeCaster.php @@ -0,0 +1,22 @@ +property->getDocComment(); + $objectMapper = new ObjectMapper(); + + if ($comment !== false) { + $mapped = []; + $pattern = sprintf(self::DOCBLOCK_PROPERTY_TYPE_PATTERN, $this->property->getName()); + + preg_match($pattern, $comment, $matches); + + if (isset($matches[self::DOCBLOCK_PROPERTY_TYPE])) { + $class = rtrim($matches[self::DOCBLOCK_PROPERTY_TYPE], '[]'); + + foreach ($value as $item) { + $mapped[] = $objectMapper->map(iterable: $item, class: $class); + } + } + + return $mapped; + } + + return $objectMapper->map(iterable: $value, class: $this->property->getType()->getName()); + } +} diff --git a/src/Internal/Mappers/Object/Casters/Types/EnumCaster.php b/src/Internal/Mappers/Object/Casters/Types/EnumCaster.php new file mode 100644 index 0000000..23fb829 --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/Types/EnumCaster.php @@ -0,0 +1,36 @@ +class); + + foreach ($reflectionEnum->getCases() as $case) { + $caseInstance = $case->getValue(); + + if ($case->getEnum()->isBacked() && $case->getBackingValue() === $value) { + return $caseInstance; + } + + if ($caseInstance->name === $value) { + return $caseInstance; + } + } + + throw InvalidCast::forEnumValue(value: $value, class: $this->class); + } +} diff --git a/src/Internal/Mappers/Object/Casters/Types/GeneratorCaster.php b/src/Internal/Mappers/Object/Casters/Types/GeneratorCaster.php new file mode 100644 index 0000000..b3a347a --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/Types/GeneratorCaster.php @@ -0,0 +1,24 @@ +getProperties(); + $instance = $reflectionClass->newInstanceWithoutConstructor(); + + $data = iterator_to_array($iterable); + + foreach ($properties as $property) { + $value = $data[$property->getName()] ?? $data; + + $caster = new CastingHandler(value: $value, targetProperty: $property); + $castedValue = $caster->applyCast(); + + $property->setValue($instance, $castedValue); + } + + return $instance; + } +} diff --git a/src/IterableMappability.php b/src/IterableMappability.php new file mode 100644 index 0000000..a785629 --- /dev/null +++ b/src/IterableMappability.php @@ -0,0 +1,25 @@ +map(value: $this->toArray(keyPreservation: $keyPreservation)); + } + + public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array + { + $mapper = new ArrayMapper(); + + return $mapper->map(value: $this, keyPreservation: $keyPreservation); + } +} diff --git a/src/IterableMapper.php b/src/IterableMapper.php new file mode 100644 index 0000000..39bb6d4 --- /dev/null +++ b/src/IterableMapper.php @@ -0,0 +1,12 @@ +map(iterable: $iterable, class: static::class); + } + + public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string + { + $mapper = new JsonMapper(); + + return $mapper->map(value: $this->toArray(keyPreservation: $keyPreservation)); + } + + public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array + { + $mapper = new ArrayMapper(); + + return $mapper->map(value: $this, keyPreservation: $keyPreservation); + } +} diff --git a/src/ObjectMapper.php b/src/ObjectMapper.php new file mode 100644 index 0000000..c71df03 --- /dev/null +++ b/src/ObjectMapper.php @@ -0,0 +1,23 @@ + $this->value]; + } +} diff --git a/tests/Models/Dragon.php b/tests/Models/Dragon.php new file mode 100644 index 0000000..9d1bda6 --- /dev/null +++ b/tests/Models/Dragon.php @@ -0,0 +1,21 @@ +elements = is_array($elements) ? $elements : iterator_to_array($elements); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->elements); + } +} diff --git a/tests/Models/ShippingCountry.php b/tests/Models/ShippingCountry.php new file mode 100644 index 0000000..6509ba5 --- /dev/null +++ b/tests/Models/ShippingCountry.php @@ -0,0 +1,11 @@ +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 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: new ShippingAddresses()), + 'expected' => '{"id": 9223372036854775807,"addresses":[]}' + ], + 'Shipping object with a single address' => [ + 'object' => new Shipping( + id: PHP_INT_MIN, + addresses: new ShippingAddresses( + 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: new ShippingAddresses( + 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: new ShippingAddresses()), + 'expected' => [ + 'id' => PHP_INT_MAX, + 'addresses' => [] + ] + ], + 'Shipping object with a single address' => [ + 'object' => new Shipping( + id: PHP_INT_MIN, + addresses: new ShippingAddresses( + 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: new ShippingAddresses( + 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]) + ) + ], + '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: new ShippingAddresses()) + ], + '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: new ShippingAddresses( + 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: new ShippingAddresses( + 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: new ShippingAddresses( + 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: new ShippingAddresses( + 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 + ] + ] + ] + ] + ]; + } +}