From 56420d377363f3221eef2f610687a34898d34171 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 24 Dec 2024 09:03:18 -0300 Subject: [PATCH 1/4] feat: Implements Mapper library for flexible data transformations. --- .gitattributes | 14 + .github/workflows/auto-assign.yml | 25 + .github/workflows/ci.yml | 49 ++ .gitignore | 6 + .idea/.gitignore | 8 + Makefile | 38 ++ README.md | 177 ++++++- composer.json | 76 +++ infection.json.dist | 24 + phpmd.xml | 61 +++ phpstan.neon.dist | 11 + 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/IterableMapperTest.php | 495 ++++++++++++++++++ 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 | 463 ++++++++++++++++ 53 files changed, 2367 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 .idea/.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/IterableMapperTest.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..3333ef2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea + +/vendor/ +/report +*.lock +.phpunit.* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml 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..7347ed8 --- /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 --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..27a319c --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + - '#method#' + - '#expects#' + - '#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..d874a1e --- /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); + } +} 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 @@ +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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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: new ShippingAddresses()) + ], + 'expected' => '[{"id":9223372036854775807,"addresses":[]}]' + ], + 'Shipping object with a single address' => [ + 'elements' => [ + 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' => [ + 'elements' => [ + 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 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: new ShippingAddresses()) + ], + 'expected' => [ + [ + 'id' => PHP_INT_MAX, + 'addresses' => [] + ] + ] + ], + 'Shipping object with a single address' => [ + 'elements' => [ + 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' => [ + 'elements' => [ + 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 dataProviderForToJsonDiscardKeys(): iterable + { + return [ + 'Scalar collection' => [ + 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], + 'expected' => '[12.34,"apple",100,true]' + ], + 'ArrayIterator collection' => [ + 'elements' => new ArrayIterator([ + 'float' => 12.34, + 'string' => 'apple', + 'integer' => 100, + 'boolean' => true + ]), + 'expected' => '[12.34,"apple",100,true]' + ] + ]; + } + + public static function dataProviderForToJsonPreserveKeys(): iterable + { + return [ + 'Scalar collection' => [ + 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], + 'expected' => '{"float":12.34,"string":"apple","integer":100,"boolean":true}' + ], + 'ArrayIterator collection' => [ + 'elements' => new ArrayIterator([ + 'float' => 12.34, + 'string' => 'apple', + 'integer' => 100, + 'boolean' => true + ]), + 'expected' => '{"float":12.34,"string":"apple","integer":100,"boolean":true}' + ] + ]; + } + + public static function dataProviderForToArrayDiscardKeys(): iterable + { + return [ + 'Scalar collection' => [ + 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], + 'expected' => [12.34, 'apple', 100, true] + ], + 'ArrayIterator collection' => [ + 'elements' => new ArrayIterator([ + 'float' => 12.34, + 'string' => 'apple', + 'integer' => 100, + 'boolean' => true + ]), + 'expected' => [12.34, 'apple', 100, true] + ] + ]; + } + + public static function dataProviderForToArrayPreserveKeys(): iterable + { + return [ + 'Scalar collection' => [ + 'elements' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true], + 'expected' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true] + ], + 'ArrayIterator collection' => [ + 'elements' => new ArrayIterator([ + 'float' => 12.34, + 'string' => 'apple', + 'integer' => 100, + 'boolean' => true + ]), + 'expected' => ['float' => 12.34, 'string' => 'apple', 'integer' => 100, 'boolean' => true] + ] + ]; + } +} diff --git a/tests/Models/Amount.php b/tests/Models/Amount.php new file mode 100644 index 0000000..727f284 --- /dev/null +++ b/tests/Models/Amount.php @@ -0,0 +1,22 @@ + $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('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: 1, + country: ShippingCountry::UNITED_STATES + ), + new ShippingAddress( + city: 'New York', + state: ShippingState::NY, + street: 'Broadway', + number: 42, + country: ShippingCountry::UNITED_STATES + ) + ] + ) + ) + ] + ]; + } +} From c09f2931e7c3ed114ad53fb5e0fa26e70629bd32 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 24 Dec 2024 09:04:16 -0300 Subject: [PATCH 2/4] Remove .idea from repository and update .gitignore --- .gitignore | 1 + .idea/.gitignore | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 .idea/.gitignore diff --git a/.gitignore b/.gitignore index 3333ef2..f3ca232 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /report *.lock .phpunit.* +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml From ff4362f42168b6e4d5ff09d683d1271e6624ba5f Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 24 Dec 2024 09:04:45 -0300 Subject: [PATCH 3/4] feat: Implements Mapper library for flexible data transformations. --- .gitignore | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index f3ca232..0593f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ -.idea - -/vendor/ -/report *.lock .phpunit.* .idea/ + +/vendor/ +/report \ No newline at end of file From 0387323703d70cd7eec7b3a52cd0614f349cf691 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 24 Dec 2024 09:32:11 -0300 Subject: [PATCH 4/4] feat: Implements Mapper library for flexible data transformations. --- composer.json | 2 +- phpstan.neon.dist | 1 + src/Internal/Mappers/Json/JsonMapper.php | 2 +- tests/IterableMapperTest.php | 495 ----------------------- tests/ObjectMapperTest.php | 89 +++- 5 files changed, 91 insertions(+), 498 deletions(-) delete mode 100644 tests/IterableMapperTest.php diff --git a/composer.json b/composer.json index 7347ed8..1cb043c 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "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 --no-progress", + "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", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 27a319c..d081be5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,7 @@ parameters: ignoreErrors: - '#method#' - '#expects#' + - '#should return#' - '#not specify its types#' - '#no value type specified#' reportUnmatchedIgnoredErrors: false diff --git a/src/Internal/Mappers/Json/JsonMapper.php b/src/Internal/Mappers/Json/JsonMapper.php index d874a1e..b2b9f2c 100644 --- a/src/Internal/Mappers/Json/JsonMapper.php +++ b/src/Internal/Mappers/Json/JsonMapper.php @@ -16,6 +16,6 @@ public function map(array $value): string return '[]'; } - return json_encode($value, JSON_PRESERVE_ZERO_FRACTION); + return json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE); } } diff --git a/tests/IterableMapperTest.php b/tests/IterableMapperTest.php deleted file mode 100644 index cc6c21f..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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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 = InvoiceSummaries::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: new ShippingAddresses()) - ], - 'expected' => '[{"id":9223372036854775807,"addresses":[]}]' - ], - 'Shipping object with a single address' => [ - 'elements' => [ - 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' => [ - 'elements' => [ - 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 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: new ShippingAddresses()) - ], - 'expected' => [ - [ - 'id' => PHP_INT_MAX, - 'addresses' => [] - ] - ] - ], - 'Shipping object with a single address' => [ - 'elements' => [ - 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' => [ - 'elements' => [ - 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 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/ObjectMapperTest.php b/tests/ObjectMapperTest.php index 59e187e..82c7759 100644 --- a/tests/ObjectMapperTest.php +++ b/tests/ObjectMapperTest.php @@ -52,6 +52,28 @@ public function testObjectToArray(ObjectMapper $object, iterable $expected): voi 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 { @@ -444,7 +466,7 @@ public static function dataProviderForIterableToObject(): iterable city: 'New York', state: ShippingState::NY, street: '5th Avenue', - number: 1, + number: 717, country: ShippingCountry::UNITED_STATES ), new ShippingAddress( @@ -460,4 +482,69 @@ public static function dataProviderForIterableToObject(): iterable ] ]; } + + 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 + ] + ] + ] + ] + ]; + } }