diff --git a/composer.json b/composer.json index 1ab80e9..b8fbcd5 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,6 @@ { "name": "tiny-blocks/mapper", "type": "library", - "version": "1.0.4", "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.", @@ -44,7 +43,7 @@ }, "require": { "php": "^8.3", - "tiny-blocks/collection": "1.9.0" + "tiny-blocks/collection": "^1" }, "require-dev": { "phpmd/phpmd": "^2.15", diff --git a/phpmd.xml b/phpmd.xml index be1b346..44cb832 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -36,8 +36,12 @@ - + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d081be5..e42932c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,6 +7,7 @@ parameters: - '#method#' - '#expects#' - '#should return#' + - '#type mixed supplied#' - '#not specify its types#' - '#no value type specified#' reportUnmatchedIgnoredErrors: false diff --git a/src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php b/src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php new file mode 100644 index 0000000..1629c50 --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/ArrayIteratorCaster.php @@ -0,0 +1,15 @@ +parameter->getType()->getName(); + + $caster = match (true) { + $class === Generator::class, => new GeneratorCaster(), + $class === ArrayIterator::class, => new ArrayIteratorCaster(), + is_subclass_of($class, UnitEnum::class) => new EnumCaster(class: $class), + is_subclass_of($class, Collectible::class) => new CollectionCaster(class: $class), + is_subclass_of($class, DateTimeInterface::class) => new DateTimeCaster(), + default => new DefaultCaster(class: $class) + }; + + return $caster->castValue(value: $value); + } +} diff --git a/src/Internal/Mappers/Object/Casters/CastingHandler.php b/src/Internal/Mappers/Object/Casters/CastingHandler.php deleted file mode 100644 index 0efc27a..0000000 --- a/src/Internal/Mappers/Object/Casters/CastingHandler.php +++ /dev/null @@ -1,39 +0,0 @@ -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/CollectionCaster.php b/src/Internal/Mappers/Object/Casters/CollectionCaster.php new file mode 100644 index 0000000..753d541 --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/CollectionCaster.php @@ -0,0 +1,38 @@ +class); + /** @var IterableMapper & Collectible $instance */ + $instance = $reflectionClass->newInstanceWithoutConstructor(); + + $type = $instance->getType(); + + if ($type === $this->class) { + return $instance->createFrom(elements: $value); + } + + $mapped = []; + + foreach ($value as $item) { + $mapped[] = (new ObjectMapper())->map(iterable: $item, class: $type); + } + + return $instance->createFrom(elements: $mapped); + } +} diff --git a/src/Internal/Mappers/Object/Casters/DateTimeCaster.php b/src/Internal/Mappers/Object/Casters/DateTimeCaster.php new file mode 100644 index 0000000..077d91e --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/DateTimeCaster.php @@ -0,0 +1,15 @@ +class)) { + return $value; + } + + return (new ObjectMapper())->map(iterable: $value, class: $this->class); + } +} diff --git a/src/Internal/Mappers/Object/Casters/Types/EnumCaster.php b/src/Internal/Mappers/Object/Casters/EnumCaster.php similarity index 74% rename from src/Internal/Mappers/Object/Casters/Types/EnumCaster.php rename to src/Internal/Mappers/Object/Casters/EnumCaster.php index 23fb829..05c8606 100644 --- a/src/Internal/Mappers/Object/Casters/Types/EnumCaster.php +++ b/src/Internal/Mappers/Object/Casters/EnumCaster.php @@ -2,20 +2,19 @@ declare(strict_types=1); -namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters\Types; +namespace TinyBlocks\Mapper\Internal\Mappers\Object\Casters; use ReflectionEnum; use TinyBlocks\Mapper\Internal\Exceptions\InvalidCast; -use TinyBlocks\Mapper\Internal\Mappers\Object\Casters\TypeCaster; use UnitEnum; -final readonly class EnumCaster implements TypeCaster +final readonly class EnumCaster implements Caster { public function __construct(public string $class) { } - public function applyCast(mixed $value): UnitEnum + public function castValue(mixed $value): UnitEnum { $reflectionEnum = new ReflectionEnum($this->class); diff --git a/src/Internal/Mappers/Object/Casters/GeneratorCaster.php b/src/Internal/Mappers/Object/Casters/GeneratorCaster.php new file mode 100644 index 0000000..6d0b7e2 --- /dev/null +++ b/src/Internal/Mappers/Object/Casters/GeneratorCaster.php @@ -0,0 +1,23 @@ +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/GeneratorCaster.php b/src/Internal/Mappers/Object/Casters/Types/GeneratorCaster.php deleted file mode 100644 index b3a347a..0000000 --- a/src/Internal/Mappers/Object/Casters/Types/GeneratorCaster.php +++ /dev/null @@ -1,24 +0,0 @@ -getProperties(); - $instance = $reflectionClass->newInstanceWithoutConstructor(); + $reflectionClass = Reflector::reflectFrom(class: $class); - $data = iterator_to_array($iterable); + $parameters = $reflectionClass->getParameters(); + $inputProperties = iterator_to_array($iterable); + $constructorArguments = []; - foreach ($properties as $property) { - $value = $data[$property->getName()] ?? $data; + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $value = $inputProperties[$name] ?? null; - $caster = new CastingHandler(value: $value, targetProperty: $property); - $castedValue = $caster->applyCast(); + if ($value !== null) { + $caster = new CasterHandler(parameter: $parameter); + $castedValue = $caster->castValue(value: $value); - $property->setValue($instance, $castedValue); + $constructorArguments[] = $castedValue; + continue; + } + + $constructorArguments[] = $parameter->getDefaultValue(); } - return $instance; + return $reflectionClass->newInstance(constructorArguments: $constructorArguments); } } diff --git a/src/Internal/Mappers/Object/Reflector.php b/src/Internal/Mappers/Object/Reflector.php new file mode 100644 index 0000000..1faec36 --- /dev/null +++ b/src/Internal/Mappers/Object/Reflector.php @@ -0,0 +1,49 @@ +constructor = $reflectionClass->getConstructor(); + $this->parameters = $this->constructor ? $this->constructor->getParameters() : []; + } + + public static function reflectFrom(string $class): Reflector + { + return new Reflector(reflectionClass: new ReflectionClass($class)); + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function newInstance(array $constructorArguments): mixed + { + $instance = $this->constructor && $this->constructor->isPrivate() + ? $this->newInstanceWithoutConstructor() + : $this->reflectionClass->newInstanceArgs($constructorArguments); + + if ($this->constructor && $this->constructor->isPrivate()) { + $this->constructor->invokeArgs($instance, $constructorArguments); + } + + return $instance; + } + + public function newInstanceWithoutConstructor(): mixed + { + return $this->reflectionClass->newInstanceWithoutConstructor(); + } +} diff --git a/src/IterableMappability.php b/src/IterableMappability.php index a785629..cfbe7e9 100644 --- a/src/IterableMappability.php +++ b/src/IterableMappability.php @@ -22,4 +22,9 @@ public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRES return $mapper->map(value: $this, keyPreservation: $keyPreservation); } + + public function getType(): string + { + return static::class; + } } diff --git a/src/IterableMapper.php b/src/IterableMapper.php index 39bb6d4..3fd5161 100644 --- a/src/IterableMapper.php +++ b/src/IterableMapper.php @@ -9,4 +9,10 @@ */ interface IterableMapper extends Mapper { + /** + * Get the type of the iterable collection of objects. + * + * @return string The type of the objects in the collection. + */ + public function getType(): string; } diff --git a/tests/IterableMapperTest.php b/tests/IterableMapperTest.php index 1c44fff..1c379a2 100644 --- a/tests/IterableMapperTest.php +++ b/tests/IterableMapperTest.php @@ -190,7 +190,7 @@ public static function dataProviderForToJson(): iterable ], 'Shipping object with no addresses' => [ 'elements' => [ - new Shipping(id: PHP_INT_MAX, addresses: new ShippingAddresses()) + new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()) ], 'expected' => '[{"id":9223372036854775807,"addresses":[]}]' ], @@ -198,7 +198,7 @@ public static function dataProviderForToJson(): iterable 'elements' => [ new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo', @@ -217,7 +217,7 @@ public static function dataProviderForToJson(): iterable 'elements' => [ new Shipping( id: 100000, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'New York', @@ -327,7 +327,7 @@ public static function dataProviderForToArray(): iterable ], 'Shipping object with no addresses' => [ 'elements' => [ - new Shipping(id: PHP_INT_MAX, addresses: new ShippingAddresses()) + new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()) ], 'expected' => [ [ @@ -340,7 +340,7 @@ public static function dataProviderForToArray(): iterable 'elements' => [ new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo', @@ -372,7 +372,7 @@ public static function dataProviderForToArray(): iterable 'elements' => [ new Shipping( id: 100000, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'New York', diff --git a/tests/Models/Collection.php b/tests/Models/Collection.php index 53b85f9..3795180 100644 --- a/tests/Models/Collection.php +++ b/tests/Models/Collection.php @@ -6,6 +6,7 @@ use Closure; use TinyBlocks\Collection\Collectible; +use TinyBlocks\Collection\Order; use TinyBlocks\Mapper\IterableMappability; use TinyBlocks\Mapper\IterableMapper; use Traversable; @@ -126,10 +127,8 @@ public function reduce(Closure $aggregator, mixed $initial): mixed // TODO: Implement reduce() method. } - public function sort( - \TinyBlocks\Collection\Order $order = \TinyBlocks\Collection\Order::ASCENDING_KEY, - ?Closure $predicate = null - ): Collectible { + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible + { // TODO: Implement sort() method. } diff --git a/tests/Models/Customer.php b/tests/Models/Customer.php new file mode 100644 index 0000000..b4b5203 --- /dev/null +++ b/tests/Models/Customer.php @@ -0,0 +1,17 @@ +elements = is_array($elements) ? $elements : iterator_to_array($elements); - } - - public function getIterator(): Traversable + public function getType(): string { - return new ArrayIterator($this->elements); + return ShippingAddress::class; } } diff --git a/tests/Models/Stores.php b/tests/Models/Stores.php new file mode 100644 index 0000000..f2a38f8 --- /dev/null +++ b/tests/Models/Stores.php @@ -0,0 +1,139 @@ +iterator = $iterator; + } + + public static function createFrom(iterable $elements): Stores + { + return new Stores(iterator: $elements); + } + + public static function createFromEmpty(): Collectible + { + // TODO: Implement createFromEmpty() method. + } + + public function add(...$elements): Collectible + { + // TODO: Implement add() method. + } + + public function contains(mixed $element): bool + { + // TODO: Implement contains() method. + } + + public function count(): int + { + return iterator_count($this->iterator); + } + + public function each(Closure ...$actions): Collectible + { + // TODO: Implement each() method. + } + + public function equals(Collectible $other): bool + { + // TODO: Implement equals() method. + } + + public function filter(?Closure ...$predicates): Collectible + { + // TODO: Implement filter() method. + } + + public function findBy(Closure ...$predicates): mixed + { + // TODO: Implement findBy() method. + } + + public function first(mixed $defaultValueIfNotFound = null): mixed + { + // TODO: Implement first() method. + } + + public function flatten(): Collectible + { + // TODO: Implement flatten() method. + } + + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed + { + // TODO: Implement getBy() method. + } + + public function getIterator(): Traversable + { + yield from $this->iterator; + } + + public function groupBy(Closure $grouping): Collectible + { + // TODO: Implement groupBy() method. + } + + public function isEmpty(): bool + { + // TODO: Implement isEmpty() method. + } + + public function joinToString(string $separator): string + { + // TODO: Implement joinToString() method. + } + + public function last(mixed $defaultValueIfNotFound = null): mixed + { + // TODO: Implement last() method. + } + + public function map(Closure ...$transformations): Collectible + { + // TODO: Implement map() method. + } + + public function remove(mixed $element): Collectible + { + // TODO: Implement remove() method. + } + + public function removeAll(?Closure $filter = null): Collectible + { + // TODO: Implement removeAll() method. + } + + public function reduce(Closure $aggregator, mixed $initial): mixed + { + // TODO: Implement reduce() method. + } + + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible + { + // TODO: Implement sort() method. + } + + public function slice(int $index, int $length = -1): Collectible + { + // TODO: Implement slice() method. + } +} diff --git a/tests/ObjectMapperTest.php b/tests/ObjectMapperTest.php index 82c7759..d64498f 100644 --- a/tests/ObjectMapperTest.php +++ b/tests/ObjectMapperTest.php @@ -14,11 +14,13 @@ use TinyBlocks\Mapper\Models\Amount; use TinyBlocks\Mapper\Models\Configuration; use TinyBlocks\Mapper\Models\Currency; +use TinyBlocks\Mapper\Models\Customer; use TinyBlocks\Mapper\Models\Decimal; use TinyBlocks\Mapper\Models\Dragon; use TinyBlocks\Mapper\Models\DragonSkills; use TinyBlocks\Mapper\Models\DragonType; use TinyBlocks\Mapper\Models\ExpirationDate; +use TinyBlocks\Mapper\Models\Merchant; use TinyBlocks\Mapper\Models\Order; use TinyBlocks\Mapper\Models\Product; use TinyBlocks\Mapper\Models\Service; @@ -27,6 +29,7 @@ use TinyBlocks\Mapper\Models\ShippingAddresses; use TinyBlocks\Mapper\Models\ShippingCountry; use TinyBlocks\Mapper\Models\ShippingState; +use TinyBlocks\Mapper\Models\Stores; final class ObjectMapperTest extends TestCase { @@ -87,6 +90,20 @@ public function testIterableToObject(iterable $iterable, ObjectMapper $expected) self::assertNotSame($expected, $actual); } + public function testIterableToObjectWithDefaultParameterValue(): void + { + /** @Given an iterable with values */ + $iterable = ['name' => 'Zephyrax the Tempest']; + + /** @When converting the iterable to an object */ + $actual = Customer::fromIterable(iterable: $iterable); + + /** @Then the result should have the default value for the missing parameter */ + self::assertSame('Zephyrax the Tempest', $actual->name); + self::assertSame(0, $actual->score); + self::assertNull($actual->gender); + } + public function testInvalidObjectValueToArrayReturnsEmptyArray(): void { /** @Given an object with an invalid item (e.g., a function that cannot be serialized) */ @@ -161,13 +178,13 @@ public static function dataProviderForToJson(): iterable 'expected' => '{"value":"2000-01-01 00:00:00"}' ], 'Shipping object with no addresses' => [ - 'object' => new Shipping(id: PHP_INT_MAX, addresses: new ShippingAddresses()), + 'object' => new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()), 'expected' => '{"id": 9223372036854775807,"addresses":[]}' ], 'Shipping object with a single address' => [ 'object' => new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo', @@ -184,7 +201,7 @@ public static function dataProviderForToJson(): iterable 'Shipping object with multiple addresses' => [ 'object' => new Shipping( id: 100000, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'New York', @@ -270,7 +287,7 @@ public static function dataProviderForToArray(): iterable 'expected' => ['value' => '2000-01-01 00:00:00'] ], 'Shipping object with no addresses' => [ - 'object' => new Shipping(id: PHP_INT_MAX, addresses: new ShippingAddresses()), + 'object' => new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()), 'expected' => [ 'id' => PHP_INT_MAX, 'addresses' => [] @@ -279,7 +296,7 @@ public static function dataProviderForToArray(): iterable 'Shipping object with a single address' => [ 'object' => new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo', @@ -307,7 +324,7 @@ public static function dataProviderForToArray(): iterable 'Shipping object with multiple addresses' => [ 'object' => new Shipping( id: 100000, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'New York', @@ -391,6 +408,26 @@ public static function dataProviderForIterableToObject(): iterable stockBatch: new ArrayIterator([1000, 2000, 3000]) ) ], + 'Customer object' => [ + 'iterable' => ['name' => 'Zephyrax the Tempest'], + 'expected' => new Customer(name: 'Zephyrax the Tempest', score: 0, gender: null) + ], + 'Merchant object' => [ + 'iterable' => [ + 'id' => '1dc6ca7a-e5f9-4c04-8fdf-16630c3009e3', + 'stores' => [ + ['name' => 'Store A'], + ['name' => 'Store B'] + ] + ], + 'expected' => new Merchant( + id: '1dc6ca7a-e5f9-4c04-8fdf-16630c3009e3', + stores: Stores::createFrom(elements: [ + ['name' => 'Store A'], + ['name' => 'Store B'] + ]) + ) + ], 'Configuration object' => [ 'iterable' => [ 'id' => PHP_INT_MAX, @@ -408,7 +445,7 @@ public static function dataProviderForIterableToObject(): iterable ], 'Shipping object with no addresses' => [ 'iterable' => ['id' => PHP_INT_MAX, 'addresses' => []], - 'expected' => new Shipping(id: PHP_INT_MAX, addresses: new ShippingAddresses()) + 'expected' => new Shipping(id: PHP_INT_MAX, addresses: ShippingAddresses::createFromEmpty()) ], 'Shipping object with a single address' => [ 'iterable' => [ @@ -425,7 +462,7 @@ public static function dataProviderForIterableToObject(): iterable ], 'expected' => new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo', @@ -460,7 +497,7 @@ public static function dataProviderForIterableToObject(): iterable ], 'expected' => new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'New York', @@ -493,7 +530,7 @@ public static function dataProviderForToJsonDiscardKeys(): iterable 'Shipping object with a single address' => [ 'object' => new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo', @@ -520,7 +557,7 @@ public static function dataProviderForToArrayDiscardKeys(): iterable 'Shipping object with a single address' => [ 'object' => new Shipping( id: PHP_INT_MIN, - addresses: new ShippingAddresses( + addresses: ShippingAddresses::createFrom( elements: [ new ShippingAddress( city: 'São Paulo',