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