diff --git a/CHANGELOG.md b/CHANGELOG.md index fd69eb70..6c006e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,25 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- [GH#297](https://github.com/jolicode/automapper/pull/297) : Support PHP 8.5 and Symfony 8, this library now use the `TypeInfo` Component for types instead of PropertyInfo directly. -- Debug command now show the type of each property mapped, transformers will also display more information. -- Profiler now show the type of each property mapped, transformers will also display more information. -- Add a castor task to serve the symfony app in tests for debugging purpose. -- [GH#304](https://github.com/jolicode/automapper/pull/304) ; Allow to override source and/or target property type -- Add support for static callable in attribute transformer -- Initial support for nested properties -- Add support for object invokable transformer in attribute transformer -- Add a new interface `PropertyTransformerComputeInterface` to allow property transformers with supports, to compute a value that will be fixed during code generation. -- Support ObjectMapper attributes -- Add an implementation for Symfony `ObjectMapperInterface` using AutoMapper +- [GH#297](https://github.com/jolicode/automapper/pull/297) Support PHP 8.5 and Symfony 8, this library now use the `TypeInfo` Component for types instead of PropertyInfo directly. +- [GH#297](https://github.com/jolicode/automapper/pull/297) Debug command now show the type of each property mapped, transformers will also display more information. +- [GH#297](https://github.com/jolicode/automapper/pull/297) Profiler now show the type of each property mapped, transformers will also display more information. +- [GH#304](https://github.com/jolicode/automapper/pull/304) Allow to override source and/or target property type. +- [GH#314](https://github.com/jolicode/automapper/pull/314) Add support for static callable in attribute transformer. +- [GH#317](https://github.com/jolicode/automapper/pull/317) Initial support for nested properties. +- [GH#318](https://github.com/jolicode/automapper/pull/318) Add support for lazy mapping. +- [GH#316](https://github.com/jolicode/automapper/pull/316) Add support for object invokable transformer in attribute transformer. +- [GH#319](https://github.com/jolicode/automapper/pull/319) Add support for discriminator with `Mapper` attribute. +- [GH#320](https://github.com/jolicode/automapper/pull/320) Add a new interface `PropertyTransformerComputeInterface` to allow property transformers with supports, to compute a value that will be fixed during code generation. +- [GH#306](https://github.com/jolicode/automapper/pull/306) Support ObjectMapper attributes. +- [GH#306](https://github.com/jolicode/automapper/pull/306) Add an implementation for Symfony `ObjectMapperInterface` using AutoMapper. ### Changed -- [BC Break] `PropertyTransformerSupportInterface` does not use a `TypesMatching` anymore, you can get the type directly from `SourcePropertyMetadata` or `TargetPropertyMetadata`. -- [BC BREAK] `ProviderInterface::provide` method now receive also the identifiers of the object to provide. +- **[BC Break]** [GH#297](https://github.com/jolicode/automapper/pull/297) `PropertyTransformerSupportInterface` does not use a `TypesMatching` anymore, you can get the type directly from `SourcePropertyMetadata` or `TargetPropertyMetadata`. +- **[BC Break]** [GH#297](https://github.com/jolicode/automapper/pull/297) `ProviderInterface::provide` method now receive also the identifiers of the object to provide. ### Fixed - [GH#303](https://github.com/jolicode/automapper/pull/302) Fix api platform not returning an iri when there is no property mapped. +### Miscellaneous +- [GH#297](https://github.com/jolicode/automapper/pull/297) Add a castor task to serve the symfony app in tests for debugging purpose. + ## [9.5.0] - 2025-09-18 ### Added - [GH#260](https://github.com/jolicode/automapper/pull/260) Add support for identifiers detection and comparison of objects, this allow mappers to detect if objects are equals based on some properties, which allow better deep merge / update of collections. diff --git a/docs/_nav.md b/docs/_nav.md index ef15c01e..8f0a92ee 100644 --- a/docs/_nav.md +++ b/docs/_nav.md @@ -7,22 +7,25 @@ - [Mapping](mapping/index.md) - [Mapper attribute](mapping/mapper-attribute.md) - [MapTo and MapFrom attributes](mapping/attributes.md) - - [Symfony Serializer](mapping/serializer.md) + - [Nested properties](mapping/nested.md) - [Mapping Collections](mapping/map-collection.md) - [Ignoring properties](mapping/ignoring-properties.md) - [Conditional mapping](mapping/conditional-mapping.md) - [Groups](mapping/groups.md) - [Transformer](mapping/transformer.md) - [Provider](mapping/provider.md) + - [Type](mapping/type.md) - [Mapping inheritance](mapping/inheritance.md) - [Identifier: mapping existing objects](mapping/identifier.md) - [DateTime format](mapping/date-time.md) + - [Symfony Serializer](mapping/serializer.md) - [Symfony Bundle](bundle/index.md) - [Installation](bundle/installation.md) - [Configuration](bundle/configuration.md) - [Cache Warmup](bundle/cache-warmup.md) - [Expression Language](bundle/expression-language.md) - [Api Platform](bundle/api-platform.md) + - [Object Mapper](bundle/object-mapper.md) - [Migrate existing application](bundle/migrate.md) - [Debugging](bundle/debugging.md) - [Upgrading](upgrading/upgrading-10.0.md) diff --git a/docs/bundle/configuration.md b/docs/bundle/configuration.md index 00d38c56..8e128f7b 100644 --- a/docs/bundle/configuration.md +++ b/docs/bundle/configuration.md @@ -26,6 +26,7 @@ automapper: reload_strategy: "always" serializer_attributes: true api_platform: false + object_mapper: false name_converter: null mapping: paths: @@ -79,6 +80,8 @@ indicate if we use the attribute of the symfony/serializer during the mapping, t `#[MaxDepth]`, `#[Ignore]` and `#[DiscriminatorMap]` attributes; * `api_platform` (default: `false`): A boolean which indicate if we use services from the api-platform/core package and inject extra data (json ld) in the mappers when we map a Resource class to or from an array. +* `object_mapper` (default: `false`): A boolean which indicate if we use attributes from the symfony/object-mapper +component to configure the mapping, and use AutoMapper as an implementation for `ObjectMapperInterface` * `doctrine` (default: `false`): A boolean which indicate if we want to use service from doctrine to extract more information about the classes, and use doctrine to fetch existing objects. It will use by default the `doctrine.orm.entity_manager` service if present, will then try to use the `doctrine_mongodb.odm.document_manager` service, or throw an exception if none of them diff --git a/docs/bundle/index.md b/docs/bundle/index.md index c3ca0908..443270dc 100644 --- a/docs/bundle/index.md +++ b/docs/bundle/index.md @@ -8,5 +8,6 @@ features linked to Symfony way of doing things. - [Cache Warmup](cache-warmup.md) - [Expression Language](expression-language.md) - [Api Platform](api-platform.md) +- [Object Mapper](object-mapper.md) - [Migrate existing application](migrate.md) - [Debugging](debugging.md) diff --git a/docs/bundle/object-mapper.md b/docs/bundle/object-mapper.md new file mode 100644 index 00000000..54178f5f --- /dev/null +++ b/docs/bundle/object-mapper.md @@ -0,0 +1,14 @@ +# Object Mapper integration + +> [!WARNING] +> The object mapper integration is in a experimental state, and may change in the future. +> Some behavior may not be handled correctly, and some features may not be implemented. +> +> If you find a bug or missing feature, please report it on the [issue tracker](https://github.com/jolicode/automapper/issues). + +This bundle provides a way to integrate with [Object Mapper Component of Symfony](https://symfony.com/doc/current/object_mapper.html) +by reading the mapping metadata from the Object Mapper `#[Map]` attributes. and also by providing an +implementation of the `Symfony\Component\ObjectMapper\ObjectMapperInterface` interface using the +AutoMapper library. + +You have to enable the `object_mapper` option in the configuration to use this feature. diff --git a/docs/mapping/index.md b/docs/mapping/index.md index b21029a0..8e110824 100644 --- a/docs/mapping/index.md +++ b/docs/mapping/index.md @@ -5,7 +5,7 @@ a `source` and a `target`. - [Mapper attribute](mapper-attribute.md) - [MapTo and MapFrom attributes](attributes.md) -- [Symfony Serializer attributes](serializer.md) +- [Nested properties](nested.md) - [Mapping a collection](map-collection.md) - [Ignoring properties](ignoring-properties.md) - [Conditional mapping](conditional-mapping.md) @@ -16,3 +16,4 @@ a `source` and a `target`. - [Mapping inheritance](inheritance.md) - [Identifier: mapping existing objects](identifier.md) - [DateTime format](date-time.md) +- [Symfony Serializer attributes](serializer.md) diff --git a/docs/mapping/inheritance.md b/docs/mapping/inheritance.md index 858e43c7..8fdb136a 100644 --- a/docs/mapping/inheritance.md +++ b/docs/mapping/inheritance.md @@ -3,19 +3,47 @@ A `source` or `target` class may inherit from another class. When creating the mapping, AutoMapper can determine the correct mapping by using the inheritance information from -the Symfony Serializer `#[DiscriminatorMap]` attribute. +the `#[Mapper]` attribute. ```php -#[DiscriminatorMap(typeProperty: 'type', mapping: [ - 'cat' => Cat::class, - 'dog' => Dog::class, - 'fish' => Fish::class, -])] +#[Mapper(discriminator: new Discriminator( + mapping: [ + DogDto::class => Dog::class, + CatDto::class => Cat::class, + ] +))] abstract class Pet { /** @var string */ - public $type; + public $name; + + /** @var PetOwner */ + public $owner; +} +``` + +When mapping a `Pet` object, AutoMapper will automatically determine the correct class to instantiate based on +the instance of the property. +If it's a `Dog` class it will map to a `DogDto` class, and if it's a `Cat` class it will map to a `CatDto` class. + +Note that the key is the `target` class, and the value is the `source` class. + +## Mapping to an array + +When mapping to an array there is no class to determine the correct mapping. In this case, instead of using the instance +of the object, AutoMapper will use the value of a specific property to determine the correct mapping. + +```php +#[Mapper(target: 'array', discriminator: new Discriminator( + property: 'type', + mapping: [ + 'dog' => Dog::class, + 'cat' => Cat::class, + ] +))] +abstract class Pet +{ /** @var string */ public $name; @@ -24,9 +52,11 @@ abstract class Pet } ``` -When mapping a `Pet` object, AutoMapper will automatically determine the correct class to instantiate based on the `type` property. +In this example, when mapping to / from an array, AutoMapper will write / read the `type` property to determine +the correct mapping. -[Learn more about the Symfony Serializer inheritance mapping](https://symfony.com/doc/current/components/serializer.html#serializing-interfaces-and-abstract-classes) +If the `type` property is equal to `dog`, it will map to / from the `Dog` class, and if it's equal to `cat`, +it will map to / from the `Cat` class. > [!NOTE] -> If you don't use the Symfony Serializer we do not provide, yet, any way to determine the correct class to instantiate. +> It also possible to use the same principle when mapping to a data structure that don't have inheritance. diff --git a/docs/mapping/nested.md b/docs/mapping/nested.md new file mode 100644 index 00000000..d1184079 --- /dev/null +++ b/docs/mapping/nested.md @@ -0,0 +1,52 @@ +# Nested properties + +The `#[MapTo]` and `#[MapFrom]` attributes support mapping to and from nested properties using dot notation in the `property` parameter +of the attributes. + +```php +class UserDto +{ + #[MapTo(property: 'address.street')] + public string $streetAddress; + + #[MapTo(property: 'address.city')] + public string $cityAddress; + + public string $name; +} + +class User +{ + public Address $address; + public string $name; + + public function __construct() + { + $this->address = new Address(); + } +} + +class Address +{ + public string $street; + public string $city; +} +``` + +When mapping from `UserDto` to `User`, the `streetAddress` and `cityAddress` properties will be mapped to the `street` and `city` properties of the nested `address` property in the `User` class. + +```php +$mapper->map(new UserDto( + streetAddress: '123 Main St', + cityAddress: 'Springfield', + name: 'John Doe' +), User::class); +``` + +This will result in a `User` object with an `Address` object where `street` is '123 Main St' and `city` is 'Springfield', and `name` is 'John Doe'. + +It can also works in the opposite direction when mapping from `User` to `UserDto` using the `#[MapFrom]` attribute. + +> [!WARNING] +> When using nested properties, the intermediate objects (like `Address` in this case) need to be properly +> initialized before mapping to avoid null reference errors. diff --git a/docs/mapping/serializer.md b/docs/mapping/serializer.md index 6cc39442..a6b73cf3 100644 --- a/docs/mapping/serializer.md +++ b/docs/mapping/serializer.md @@ -97,3 +97,20 @@ use Symfony\Component\Serializer\Serializer; $autoMapper = AutoMapper::create(); $serializer = new Serializer([new AutoMapperNormalizer($autoMapper)]); ``` + +### Discriminator Map + +The Symfony Serializer `#[DiscriminatorMap]` attribute can be used to define a discriminator map for polymorphic mapping. + +```php +use Symfony\Component\Serializer\Annotation\DiscriminatorMap; + +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'dog' => Dog::class, + 'cat' => Cat::class, +])] +abstract class Pet +{ + public $name; +} +``` diff --git a/docs/mapping/transformer.md b/docs/mapping/transformer.md index df8fe09f..f4e0e310 100644 --- a/docs/mapping/transformer.md +++ b/docs/mapping/transformer.md @@ -126,10 +126,9 @@ namespace App\Transformer; use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; -use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface; use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerSupportInterface; -class UrlTransformer implements PropertyTransformerInterface, PropertyTransformerSupportInterface +class UrlTransformer implements PropertyTransformerSupportInterface { public function __construct(private UrlGeneratorInterface $urlGenerator) { @@ -158,7 +157,7 @@ If you have multiple transformers that can be applied to the same transformation ```php namespace App\Transformer; -class UrlTransformer implements PropertyTransformerInterface, PropertyTransformerSupportInterface, PrioritizedPropertyTransformerInterface +class UrlTransformer implements PropertyTransformerSupportInterface, PrioritizedPropertyTransformerInterface { // ... @@ -170,3 +169,48 @@ class UrlTransformer implements PropertyTransformerInterface, PropertyTransforme ``` When multiple transformers can be applied, the one with the highest priority will be used. + +### Computing extra data for the transformer + +In some cases you may want to compute extra data that will be passed to the transformer. This is possible by +implementing the `PropertyTransformerDataProviderInterface` interface. + +```php +namespace App\Transformer; + +use AutoMapper\Metadata\MapperMetadata; +use AutoMapper\Metadata\SourcePropertyMetadata; +use AutoMapper\Metadata\TargetPropertyMetadata; +use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerSupportInterface; + +class UrlTransformer implements PropertyTransformerComputeInterface +{ + public function __construct() + { + } + + public function supports(SourcePropertyMetadata $source, TargetPropertyMetadata $target, MapperMetadata $mapperMetadata): bool + { + return $source->type->isIdentifiedBy(TypeIdentifier::INT && $source->property === 'id' && $target->property === 'url'; + } + + public function compute(SourcePropertyMetadata $source, TargetPropertyMetadata $target, MapperMetadata $mapperMetadata): mixed + { + // Compute extra data here + return 'some computed data'; + } + + public function transform(mixed $value, object|array $source, array $context, mixed $computed = null): mixed + { + return $computed . $value; + } +} +``` + +The value returned by the `compute` method will be created when generating the mapper and passed as a static value to +the `transform` method each time it is needed. + +> [!WARNING] +> This value is only computed when the transformer comes from guessed with the `supports` method. If you specify the transformer +> manually in the `#[MapTo]` or `#[MapFrom]` attribute, the `compute` method will not be called and there will be no value passed +> to the `transform` method. diff --git a/src/EventListener/MapperListener.php b/src/EventListener/MapperListener.php index 4d2ed5e2..d21893ef 100644 --- a/src/EventListener/MapperListener.php +++ b/src/EventListener/MapperListener.php @@ -6,6 +6,10 @@ use AutoMapper\Attribute\Mapper; use AutoMapper\Event\GenerateMapperEvent; +use AutoMapper\Event\PropertyMetadataEvent; +use AutoMapper\Event\SourcePropertyMetadata; +use AutoMapper\Event\TargetPropertyMetadata; +use AutoMapper\Transformer\FixedValueTransformer; /** * @internal @@ -18,69 +22,143 @@ public function __construct() public function __invoke(GenerateMapperEvent $event): void { - /** @var array{0: Mapper, 1: bool}[] $mappers */ - $mappers = []; - - if ($event->mapperMetadata->sourceReflectionClass) { - foreach ($event->mapperMetadata->sourceReflectionClass->getAttributes(Mapper::class) as $attribute) { - /** @var Mapper $mapper */ - $mapper = $attribute->newInstance(); - - if ($mapper->target === null) { - $mappers[] = [$mapper, true]; + // get mapper with highest priority + [$directMapperAttribute, $fromSource, $isDirect] = $this->getMapperAttribute($event, false); + + if ($directMapperAttribute) { + $event->checkAttributes ??= $directMapperAttribute->checkAttributes; + $event->constructorStrategy ??= $directMapperAttribute->constructorStrategy; + $event->allowReadOnlyTargetToPopulate ??= $directMapperAttribute->allowReadOnlyTargetToPopulate; + $event->strictTypes ??= $directMapperAttribute->strictTypes; + $event->allowExtraProperties ??= $directMapperAttribute->allowExtraProperties; + $event->mapperMetadata->dateTimeFormat = $directMapperAttribute->dateTimeFormat; + + if ($directMapperAttribute->discriminator) { + if ($fromSource) { + $event->sourceDiscriminator = $directMapperAttribute->discriminator; + } else { + $event->targetDiscriminator = $directMapperAttribute->discriminator; + + if ($directMapperAttribute->discriminator->propertyName) { + $property = $directMapperAttribute->discriminator->propertyName; + $sourceProperty = new SourcePropertyMetadata($property); + $targetProperty = new TargetPropertyMetadata($property); + + $event->properties[$property] = new PropertyMetadataEvent( + mapperMetadata: $event->mapperMetadata, + source: $sourceProperty, + target: $targetProperty, + ); + } } + } + } - if (\is_string($mapper->target) && $mapper->target === $event->mapperMetadata->target) { - $mappers[] = [$mapper, true]; - } + // get discriminator from mapper if not already set, which should also check parent class + [$attributeForDiscriminator, $fromSource, $isDirect] = $this->getMapperAttribute($event, true); + + if (null === $attributeForDiscriminator || null === $attributeForDiscriminator->discriminator || null === $attributeForDiscriminator->discriminator->propertyName) { + return; + } - if (\is_array($mapper->target) && \in_array($event->mapperMetadata->target, $mapper->target, true)) { - $mappers[] = [$mapper, true]; + // In this case we have a propertyName and not a direct discriminator let's add the property transformer for it + if ($fromSource && !$isDirect) { + foreach ($attributeForDiscriminator->discriminator->mapping as $type => $class) { + if ($class === $event->mapperMetadata->source) { + $property = $attributeForDiscriminator->discriminator->propertyName; + $sourceProperty = new SourcePropertyMetadata($property); + $targetProperty = new TargetPropertyMetadata($property); + + $event->properties[$property] = new PropertyMetadataEvent( + mapperMetadata: $event->mapperMetadata, + source: $sourceProperty, + target: $targetProperty, + transformer: new FixedValueTransformer($type), + ); } } } + } - if ($event->mapperMetadata->targetReflectionClass) { - foreach ($event->mapperMetadata->targetReflectionClass->getAttributes(Mapper::class) as $attribute) { - /** @var Mapper $mapper */ - $mapper = $attribute->newInstance(); - - if ($mapper->source === null) { - $mappers[] = [$mapper, false]; - } + /** + * @return array{0: ?Mapper, 1: bool, 2: bool} + */ + private function getMapperAttribute(GenerateMapperEvent $event, bool $allowParent): array + { + /** @var array{0: Mapper, 1: bool, 2: bool}[] $mappers */ + $mappers = []; - if (\is_string($mapper->source) && $mapper->source === $event->mapperMetadata->source) { - $mappers[] = [$mapper, false]; - } + if ($event->mapperMetadata->sourceReflectionClass) { + foreach ($this->getMappers($event->mapperMetadata->sourceReflectionClass, $event->mapperMetadata->target, true, $allowParent) as [$mapper, $isDirect]) { + $mappers[] = [$mapper, true, $isDirect]; + } + } - if (\is_array($mapper->source) && \in_array($event->mapperMetadata->source, $mapper->source, true)) { - $mappers[] = [$mapper, false]; - } + if ($event->mapperMetadata->targetReflectionClass) { + foreach ($this->getMappers($event->mapperMetadata->targetReflectionClass, $event->mapperMetadata->source, false, $allowParent) as [$mapper, $isDirect]) { + $mappers[] = [$mapper, false, $isDirect]; } } + if (0 === \count($mappers)) { - return; + return [null, false, false]; } // sort by priority usort($mappers, fn (array $a, array $b) => $a[0]->priority <=> $b[0]->priority); - // get mapper with highest priority - [$mapper, $fromSource] = $mappers[0]; - - $event->checkAttributes ??= $mapper->checkAttributes; - $event->constructorStrategy ??= $mapper->constructorStrategy; - $event->allowReadOnlyTargetToPopulate ??= $mapper->allowReadOnlyTargetToPopulate; - $event->strictTypes ??= $mapper->strictTypes; - $event->allowExtraProperties ??= $mapper->allowExtraProperties; - $event->mapperMetadata->dateTimeFormat = $mapper->dateTimeFormat; - - if ($mapper->discriminator) { - if ($fromSource) { - $event->sourceDiscriminator = $mapper->discriminator; - } else { - $event->targetDiscriminator = $mapper->discriminator; + return $mappers[0]; + } + + /** + * @param \ReflectionClass $reflectionClass + * + * @return \Generator + */ + private function getMappers(\ReflectionClass $reflectionClass, string $targetOrSource, bool $fromSource, bool $allowParent = false, bool $isDirect = true): \Generator + { + $attributes = $reflectionClass->getAttributes(Mapper::class); + + foreach ($attributes as $attribute) { + $mapper = $attribute->newInstance(); + + if ($fromSource && $mapper->target === null) { + yield [$mapper, $isDirect]; + } + + if (!$fromSource && $mapper->source === null) { + yield [$mapper, $isDirect]; + } + + if ($fromSource && \is_string($mapper->target) && $mapper->target === $targetOrSource) { + yield [$mapper, $isDirect]; + } + + if (!$fromSource && \is_string($mapper->source) && $mapper->source === $targetOrSource) { + yield [$mapper, $isDirect]; + } + + if ($fromSource && \is_array($mapper->target) && \in_array($targetOrSource, $mapper->target, true)) { + yield [$mapper, $isDirect]; } + + if (!$fromSource && \is_array($mapper->source) && \in_array($targetOrSource, $mapper->source, true)) { + yield [$mapper, $isDirect]; + } + } + + if (!$allowParent) { + return; + } + + // Include metadata from the parent class + if ($parent = $reflectionClass->getParentClass()) { + yield from $this->getMappers($parent, $targetOrSource, $fromSource, true, false); + } + + // Include metadata from all implemented interfaces + foreach ($reflectionClass->getInterfaces() as $interface) { + yield from $this->getMappers($interface, $targetOrSource, $fromSource, true, false); } } } diff --git a/src/ObjectMapper/ObjectMapper.php b/src/ObjectMapper/ObjectMapper.php index 0ef49619..0f5595f9 100644 --- a/src/ObjectMapper/ObjectMapper.php +++ b/src/ObjectMapper/ObjectMapper.php @@ -20,9 +20,9 @@ private AutoMapperInterface $autoMapper; public function __construct( - private ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), ?AutoMapperInterface $autoMapper = null, private ?ContainerInterface $serviceLocator = null, + private ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), ) { $this->autoMapper = $autoMapper ?? AutoMapper::create(); } diff --git a/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php b/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php index 092a4a0b..d76e3334 100644 --- a/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php @@ -136,6 +136,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('api_platform.php'); } + if ($config['object_mapper']) { + $loader->load('object_mapper.php'); + } + if ($config['doctrine']) { $loader->load('doctrine.php'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 8f866869..7020bb7e 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -42,6 +42,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->booleanNode('serializer_attributes')->defaultValue(interface_exists(SerializerInterface::class))->end() ->booleanNode('api_platform')->defaultFalse()->end() + ->booleanNode('object_mapper')->defaultFalse()->end() ->booleanNode('doctrine')->defaultFalse()->end() ->scalarNode('name_converter')->defaultNull()->end() ->arrayNode('loader') diff --git a/src/Symfony/Bundle/Resources/config/object_mapper.php b/src/Symfony/Bundle/Resources/config/object_mapper.php new file mode 100644 index 00000000..9722fb3c --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/object_mapper.php @@ -0,0 +1,42 @@ +services() + ->set(MapSourceListener::class) + ->args([ + service('automapper.mapper_service_locator'), + service('automapper.expression_language'), + ]) + ->tag('kernel.event_listener', ['event' => GenerateMapperEvent::class, 'priority' => 0]) + + ->set(MapTargetListener::class) + ->args([ + service('automapper.mapper_service_locator'), + service('automapper.expression_language'), + ]) + ->tag('kernel.event_listener', ['event' => GenerateMapperEvent::class, 'priority' => 0]) + + ->set('automapper.object_mapper') + ->class(ObjectMapper::class) + ->args([ + service(AutoMapperInterface::class), + service('automapper.mapper_service_locator'), + ]) + + ->set(ObjectMapperInterface::class) + ->alias(ObjectMapperInterface::class, 'automapper.object_mapper') + ->public() + ; +}; diff --git a/tests/AutoMapperTest/DiscriminatorAttributeArray/expected.to-dto.data b/tests/AutoMapperTest/DiscriminatorAttributeArray/expected.to-dto.data new file mode 100644 index 00000000..31847e4c --- /dev/null +++ b/tests/AutoMapperTest/DiscriminatorAttributeArray/expected.to-dto.data @@ -0,0 +1,7 @@ +[ + "pet" => [ + "breed" => "German Shepherd" + "name" => "Rex" + "type" => "dog" + ] +] \ No newline at end of file diff --git a/tests/AutoMapperTest/DiscriminatorAttributeArray/expected.to-obj.data b/tests/AutoMapperTest/DiscriminatorAttributeArray/expected.to-obj.data new file mode 100644 index 00000000..0666356d --- /dev/null +++ b/tests/AutoMapperTest/DiscriminatorAttributeArray/expected.to-obj.data @@ -0,0 +1,6 @@ +AutoMapper\Tests\AutoMapperTest\DiscriminatorAttributeArray\Owner { + +pet: AutoMapper\Tests\AutoMapperTest\DiscriminatorAttributeArray\Dog { + +name: "Rex" + +breed: "German Shepherd" + } +} \ No newline at end of file diff --git a/tests/AutoMapperTest/DiscriminatorAttributeArray/map.php b/tests/AutoMapperTest/DiscriminatorAttributeArray/map.php new file mode 100644 index 00000000..598b3878 --- /dev/null +++ b/tests/AutoMapperTest/DiscriminatorAttributeArray/map.php @@ -0,0 +1,61 @@ + Dog::class, + 'cat' => Cat::class, +], propertyName: 'type'))] +class Pet +{ + public function __construct( + public string $name, + ) { + } +} + +class Dog extends Pet +{ + public function __construct( + public string $name, + public string $breed, + ) { + parent::__construct($name); + } +} + +class Cat extends Pet +{ + public function __construct( + public string $name, + public bool $isIndoor, + ) { + parent::__construct($name); + } +} + +return (function () { + $autoMapper = AutoMapperBuilder::buildAutoMapper(mapPrivatePropertiesAndMethod: true); + + $pet = new Dog('Rex', 'German Shepherd'); + $owner = new Owner($pet); + $dto = $autoMapper->map($owner, 'array'); + + yield 'to-dto' => $dto; + + yield 'to-obj' => $autoMapper->map($dto, Owner::class); +})(); diff --git a/tests/Bundle/Resources/config/packages/automapper.yaml b/tests/Bundle/Resources/config/packages/automapper.yaml index 39e1374d..5752bdc9 100644 --- a/tests/Bundle/Resources/config/packages/automapper.yaml +++ b/tests/Bundle/Resources/config/packages/automapper.yaml @@ -6,6 +6,7 @@ automapper: eval: false reload_strategy: 'always' api_platform: true + object_mapper: true doctrine: true name_converter: AutoMapper\Tests\Bundle\Resources\App\Service\IdNameConverter map_private_properties: false diff --git a/tests/Bundle/ServiceInstantiationTest.php b/tests/Bundle/ServiceInstantiationTest.php index 5fae7454..2f56dcbd 100644 --- a/tests/Bundle/ServiceInstantiationTest.php +++ b/tests/Bundle/ServiceInstantiationTest.php @@ -26,6 +26,11 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; +use AutoMapper\Tests\ObjectMapper\Fixtures\A; +use AutoMapper\Tests\ObjectMapper\Fixtures\B; +use AutoMapper\Tests\ObjectMapper\Fixtures\C; +use AutoMapper\Tests\ObjectMapper\Fixtures\D; class ServiceInstantiationTest extends WebTestCase { @@ -284,4 +289,39 @@ protected function tearDown(): void parent::tearDown(); restore_exception_handler(); } + + #[DataProvider('mapProvider')] + public function testObjectMapper($expect, $args, array $deps = []) + { + static::bootKernel(); + $mapper = static::$kernel->getContainer()->get(ObjectMapperInterface::class); + $mapped = $mapper->map(...$args); + + $this->assertEquals($expect, $mapped); + } + + /** + * @return iterable + */ + public static function mapProvider(): iterable + { + $d = new D(baz: 'foo', bat: 'bar'); + $c = new C(foo: 'foo', bar: 'bar'); + $a = new A(); + $a->foo = 'test'; + $a->transform = 'test'; + $a->baz = 'me'; + $a->notinb = 'test'; + $a->relation = $c; + $a->relationNotMapped = $d; + + $b = new B('test'); + $b->transform = 'TEST'; + $b->baz = 'me'; + $b->nomap = false; + $b->concat = 'shouldtestme'; + $b->relation = $d; + $b->relationNotMapped = $d; + yield [$b, [$a]]; + } } diff --git a/tests/ObjectMapper/ObjectMapperTest.php b/tests/ObjectMapper/ObjectMapperTest.php index 1172e5f8..f43c0678 100644 --- a/tests/ObjectMapper/ObjectMapperTest.php +++ b/tests/ObjectMapper/ObjectMapperTest.php @@ -320,7 +320,7 @@ public function testTransformToWrongValueType() $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); $metadata->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: fn () => 'str')]); - $mapper = new ObjectMapper($metadata); + $mapper = new ObjectMapper(metadataFactory: $metadata); $mapper->map($u); } @@ -334,7 +334,7 @@ public function testTransformToWrongObject() $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); $metadata->method('create')->with($u)->willReturn([new Mapping(target: ClassWithoutTarget::class, transform: fn () => new \stdClass())]); - $mapper = new ObjectMapper($metadata); + $mapper = new ObjectMapper(metadataFactory: $metadata); $mapper->map($u); }