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 + ] + ] + ] + ] + ]; + } +}