diff --git a/README.md b/README.md index afeb95f..68b1156 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ The following operations are provided: | MapTo | Maps the property to another class. Allows for [nested mappings](#dealing-with-nested-mappings). Supports both single values and collections. | | FromProperty | Use this to explicitly state the source property name. | | DefaultMappingOperation | Simply transfers the property, taking into account the provided naming conventions (if there are any). | +| MapFromWithMapper | Similar to MapFrom.
Compared to `mapFrom`, the callback has access to a instance of `AutoMapperInterface`. Define the second callback argument of `AutoMapperInterface` type. Accessible by using
- `Operation::mapFromWithMapper(function($source, AutoMapperInterface $mapper){ ... })`
- `new mapFromWithMapper(function($source, AutoMapperInterface $mapper){ ... })` You can use them with the same `forMember()` method. The `Operation` class can be used for clarity. @@ -238,6 +239,35 @@ $mapping->forMember('id', Operation::ignore()); $mapping->forMember('employee', Operation::mapTo(EmployeeDto::class)); // Explicitly state what the property name is of the source object. $mapping->forMember('name', Operation::fromProperty('unconventially_named_property')); +// The `FromProperty` operation can be chained with `MapTo`, allowing a +// differently named property to be mapped to a class. +$mapping->forMember( + 'address', + Operation::fromProperty('adres')->mapTo(Address::class) +); +``` + +Example of using `MapFromWithMapper`: + +```php +xpath('/product/specification/palette/colour'); + + return $mapper->mapMultiple($palette, Color::class); +}; + +$mapping->forMember('palette', Operation::mapFromWithMapper($getColorPalette)); + +// Or another Example using inline function +$mapping->forMember('palette', new MapFromWithMapper(function(SimpleXMLElement $XMLElement, AutoMapperInterface $mapper) { + /** @var SimpleXMLElement $palette */ + $palette = $XMLElement->xpath('/product/specification/palette/colour'); + + return $mapper->mapMultiple($palette, Color::class); +})); ``` You can create your own operations by implementing the @@ -245,6 +275,10 @@ You can create your own operations by implementing the [provided implementations](https://github.com/mark-gerarts/automapper-plus/tree/master/src/MappingOperation) for some inspiration. +If you need to have the automapper available in your operation, you can +implement the `MapperAwareInterface`, and use the `MapperAwareTrait`. The +default `MapTo` and `MapFromWithMapper` operations use these. + #### Dealing with nested mappings Nested mappings can be registered using the `MapTo` operation. Keep in mind that the mapping for the child class has to be registered as well. @@ -756,3 +790,4 @@ Collection size: 10000 - [ ] Clean up the property checking in the Mapping::forMember() method. - [ ] Refactor tests - [ ] Allow setting a maximum depth, see #14 +- [ ] Provide a NameResolver that accepts an array mapping, as an alternative to multiple `FromProperty`s diff --git a/src/AutoMapper.php b/src/AutoMapper.php index f7987f6..f1d0c38 100644 --- a/src/AutoMapper.php +++ b/src/AutoMapper.php @@ -6,7 +6,7 @@ use AutoMapperPlus\Configuration\AutoMapperConfigInterface; use AutoMapperPlus\Configuration\MappingInterface; use AutoMapperPlus\Exception\UnregisteredMappingException; -use AutoMapperPlus\MappingOperation\Implementations\MapTo; +use AutoMapperPlus\MappingOperation\MapperAwareOperation; use function Functional\map; /** @@ -106,9 +106,7 @@ protected function doMap($source, $destination, MappingInterface $mapping) foreach ($propertyNames as $propertyName) { $mappingOperation = $mapping->getMappingOperationFor($propertyName); - // @todo: find another solution to this hacky implementation of - // recursive mapping. - if ($mappingOperation instanceof MapTo) { + if ($mappingOperation instanceof MapperAwareOperation) { $mappingOperation->setMapper($this); } diff --git a/src/MappingOperation/Implementations/FromProperty.php b/src/MappingOperation/Implementations/FromProperty.php index f62a98b..f8a178a 100644 --- a/src/MappingOperation/Implementations/FromProperty.php +++ b/src/MappingOperation/Implementations/FromProperty.php @@ -2,11 +2,14 @@ namespace AutoMapperPlus\MappingOperation\Implementations; +use AutoMapperPlus\AutoMapperInterface; use AutoMapperPlus\Configuration\Options; use AutoMapperPlus\MappingOperation\AlternativePropertyProvider; use AutoMapperPlus\MappingOperation\DefaultMappingOperation; +use AutoMapperPlus\MappingOperation\MapperAwareOperation; use AutoMapperPlus\MappingOperation\MappingOperationInterface; use AutoMapperPlus\MappingOperation\Reversible; +use AutoMapperPlus\NameResolver\CallbackNameResolver; /** * Class FromProperty @@ -15,8 +18,16 @@ */ class FromProperty extends DefaultMappingOperation implements AlternativePropertyProvider, - Reversible + Reversible, + // We need to be mapper aware to be able to pass the mapper to a chained + // operation. + MapperAwareOperation { + /** + * @var MappingOperationInterface|null + */ + private $nextOperation; + /** * @var string */ @@ -48,6 +59,22 @@ public function getAlternativePropertyName(): string return $this->propertyName; } + /** + * @inheritdoc + */ + public function mapProperty(string $propertyName, $source, $destination): void { + if ($this->nextOperation === null) { + parent::mapProperty($propertyName, $source, $destination); + return; + } + + $this->mapPropertyWithNextOperation( + $propertyName, + $source, + $destination + ); + } + /** * @inheritdoc */ @@ -71,4 +98,57 @@ public function getReverseTargetPropertyName { return $this->propertyName; } + + /** + * Chain a MapTo operation, making the MapTo use this operation's property + * name instead. + * + * Note: because MapTo is not reversible, the MapTo part gets lost when + * reversing the mapping. + * + * @todo: extend to other operations, or maybe a __call? + * + * @param string $class + * @return FromProperty + */ + public function mapTo(string $class): FromProperty + { + $this->nextOperation = new MapTo($class); + return $this; + } + + /** + * @param string $propertyName + * @param $source + * @param $destination + */ + protected function mapPropertyWithNextOperation( + string $propertyName, + $source, + $destination + ): void + { + // We have to make the overridden property available to the next + // operation. To do this, we create a "one-time use" name resolver + // to pass to the operation. + $options = clone $this->options; + $options->setNameResolver(new CallbackNameResolver(function () { + return $this->propertyName; + })); + $this->nextOperation->setOptions($options); + + // The chained operation will now use the property name assigned to + // FromProperty, so we can go ahead and call it. + $this->nextOperation->mapProperty($propertyName, $source, $destination); + } + + /** + * @inheritdoc + */ + public function setMapper(AutoMapperInterface $mapper): void + { + if ($this->nextOperation instanceof MapperAwareOperation) { + $this->nextOperation->setMapper($mapper); + } + } } diff --git a/src/MappingOperation/Implementations/MapFrom.php b/src/MappingOperation/Implementations/MapFrom.php index a0ca27f..0b21461 100644 --- a/src/MappingOperation/Implementations/MapFrom.php +++ b/src/MappingOperation/Implementations/MapFrom.php @@ -14,7 +14,7 @@ class MapFrom extends DefaultMappingOperation /** * @var callable */ - private $valueCallback; + protected $valueCallback; /** * MapFrom constructor. diff --git a/src/MappingOperation/Implementations/MapFromWithMapper.php b/src/MappingOperation/Implementations/MapFromWithMapper.php new file mode 100644 index 0000000..9258a94 --- /dev/null +++ b/src/MappingOperation/Implementations/MapFromWithMapper.php @@ -0,0 +1,30 @@ + + * Date: 3/14/18 + * Time: 1:16 PM + */ + +namespace AutoMapperPlus\MappingOperation\Implementations; + +use AutoMapperPlus\MappingOperation\MapperAwareOperation; +use AutoMapperPlus\MappingOperation\MapperAwareTrait; + +/** + * Class MapFromWithMapper + * + * @package AutoMapperPlus\MappingOperation\Implementations + */ +class MapFromWithMapper extends MapFrom implements MapperAwareOperation +{ + use MapperAwareTrait; + + /** + * @inheritdoc + */ + protected function getSourceValue($source, string $propertyName) + { + return ($this->valueCallback)($source, $this->mapper); + } +} diff --git a/src/MappingOperation/Implementations/MapTo.php b/src/MappingOperation/Implementations/MapTo.php index 226b91d..b3831b7 100644 --- a/src/MappingOperation/Implementations/MapTo.php +++ b/src/MappingOperation/Implementations/MapTo.php @@ -2,8 +2,9 @@ namespace AutoMapperPlus\MappingOperation\Implementations; -use AutoMapperPlus\AutoMapperInterface; use AutoMapperPlus\MappingOperation\DefaultMappingOperation; +use AutoMapperPlus\MappingOperation\MapperAwareOperation; +use AutoMapperPlus\MappingOperation\MapperAwareTrait; /** * Class MapTo. @@ -12,18 +13,15 @@ * * @package AutoMapperPlus\MappingOperation\Implementations */ -class MapTo extends DefaultMappingOperation +class MapTo extends DefaultMappingOperation implements MapperAwareOperation { + use MapperAwareTrait; + /** * @var string */ private $destinationClass; - /** - * @var AutoMapperInterface - */ - private $mapper; - /** * MapTo constructor. * @@ -42,20 +40,15 @@ public function getDestinationClass(): string return $this->destinationClass; } - /** - * @param AutoMapperInterface $mapper - */ - public function setMapper(AutoMapperInterface $mapper) - { - $this->mapper = $mapper; - } - /** * @inheritdoc */ protected function getSourceValue($source, string $propertyName) { - $value = $this->getPropertyAccessor()->getProperty($source, $propertyName); + $value = $this->getPropertyAccessor()->getProperty( + $source, + $this->getSourcePropertyName($propertyName) + ); return $this->isCollection($value) ? $this->mapper->mapMultiple($value, $this->destinationClass) diff --git a/src/MappingOperation/MapperAwareOperation.php b/src/MappingOperation/MapperAwareOperation.php new file mode 100644 index 0000000..8242c2f --- /dev/null +++ b/src/MappingOperation/MapperAwareOperation.php @@ -0,0 +1,17 @@ +mapper = $mapper; + } +} diff --git a/src/MappingOperation/Operation.php b/src/MappingOperation/Operation.php index 540ae77..ddeabb7 100644 --- a/src/MappingOperation/Operation.php +++ b/src/MappingOperation/Operation.php @@ -5,6 +5,7 @@ use AutoMapperPlus\MappingOperation\Implementations\FromProperty; use AutoMapperPlus\MappingOperation\Implementations\Ignore; use AutoMapperPlus\MappingOperation\Implementations\MapFrom; +use AutoMapperPlus\MappingOperation\Implementations\MapFromWithMapper; use AutoMapperPlus\MappingOperation\Implementations\MapTo; /** @@ -28,6 +29,22 @@ public static function mapFrom(callable $valueCallback): MapFrom return new MapFrom($valueCallback); } + /** + * Set a property's value from callback, callback should contain 2 parameters + * + * @param callable $valueCallback + * Callback definition: + * + * function(AutoMapperInterface, mixed){ + + * } + * @return MapFromWithMapper + */ + public static function mapFromWithMapper(callable $valueCallback): MapFromWithMapper + { + return new MapFromWithMapper($valueCallback); + } + /** * Ignore a property. * diff --git a/test/AutoMapperTest.php b/test/AutoMapperTest.php index 90c0f58..0d76528 100644 --- a/test/AutoMapperTest.php +++ b/test/AutoMapperTest.php @@ -10,6 +10,10 @@ use AutoMapperPlus\NameConverter\NamingConvention\SnakeCaseNamingConvention; use AutoMapperPlus\NameResolver\CallbackNameResolver; use AutoMapperPlus\Test\Models\Inheritance\SourceChild; +use AutoMapperPlus\Test\Models\Nested\Address; +use AutoMapperPlus\Test\Models\Nested\AddressDto; +use AutoMapperPlus\Test\Models\Nested\Person; +use AutoMapperPlus\Test\Models\Nested\PersonDto; use AutoMapperPlus\Test\Models\SimpleProperties\NoProperties; use PHPUnit\Framework\TestCase; use AutoMapperPlus\Test\Models\Employee\Employee; @@ -456,4 +460,79 @@ public function testANullObjectReturnsNull() $result = $mapper->map($source, Destination::class); $this->assertEquals(null, $result); } + + public function testInvalidWithMappingCallback_ThrowsException() + { + // Arrange + $source = new CamelCaseSource(); + $error = null; + + // Act + $this->config->registerMapping(CamelCaseSource::class, \stdClass::class) + ->forMember('propertyName', Operation::mapFromWithMapper(function($source, AutoMapperInterface $mapping){ + return 13; + })); + $mapper = new AutoMapper($this->config); + $result = $mapper->map($source, \stdClass::class); + + // Assert + $this->assertEquals($result, $result); + } + + public function testInstanceWithMappingCallback_InstanceIsCorrect() + { + // Arrange + $propertyStdClass = new \stdClass(); + $propertyStdClass->value = "TestValue"; + + $testSuffix = "MAPPED"; + $expectedResult = $propertyStdClass->value . $testSuffix; + + $this->config->registerMapping(\stdClass::class, Destination::class) + ->forMember('name', function($source) use ($testSuffix){ + return $source->value . $testSuffix; + }); + + $this->config->registerMapping(CamelCaseSource::class, \stdClass::class) + ->forMember('propertyName', Operation::mapFromWithMapper(function($source, AutoMapperInterface $mapping){ + // if the $mapping isn't a instance of AutoMapperInterface, it wouldn't return anything + + return $mapping->map($source->propertyName, Destination::class); + })); + $mapper = new AutoMapper($this->config); + $source = new CamelCaseSource(); + $source->propertyName = $propertyStdClass; + + // Act + $result = $mapper->map($source, \stdClass::class); + + // Assert + $this->assertEquals($expectedResult, $result->propertyName->name); + } + + /** + * @todo: move this to fromPropertyTest. + */ + public function testFromPropertyCanBeChained() + { + $config = new AutoMapperConfig(); + $config->registerMapping(Address::class, AddressDto::class) + ->forMember('streetAndNumber', function (Address $source) { + return $source->street . ' ' . $source->number; + }); + $config->registerMapping(Person::class, PersonDto::class) + ->forMember('address', Operation::fromProperty('adres')->mapTo(AddressDto::class)) + ; + $mapper = new AutoMapper($config); + + $address = new Address; + $address->street = "Main Street"; + $address->number = 12; + $person = new Person; + $person->adres = $address; + + $result = $mapper->map($person, PersonDto::class); + + $this->assertEquals("Main Street 12", $result->address->streetAndNumber); + } } diff --git a/test/MappingOperation/Implementations/MapFromWithMappingTest.php b/test/MappingOperation/Implementations/MapFromWithMappingTest.php new file mode 100644 index 0000000..798efb1 --- /dev/null +++ b/test/MappingOperation/Implementations/MapFromWithMappingTest.php @@ -0,0 +1,62 @@ +setMapper(AutoMapper::initialize(function (AutoMapperConfigInterface $config) { + $config->registerMapping(Source::class, Destination::class); + })); + $mapFromWithMapper->setOptions(Options::default()); + + $source = new Source(); + $destination = new Destination(); + + // Act + $mapFromWithMapper->mapProperty('name', $source, $destination); + + // Assert + $this->assertEquals(42, $destination->name); + } + + public function testItReceivesTheSourceObject() + { + // Arrange + $mapFromWithMapper = new MapFromWithMapper(function ($source, AutoMapperInterface $mapper) { + return $source; + }); + $mapFromWithMapper->setMapper(AutoMapper::initialize(function (AutoMapperConfigInterface $config) { + $config->registerMapping(Source::class, Destination::class); + })); + $mapFromWithMapper->setOptions(Options::default()); + + $source = new Source(); + $destination = new Destination(); + + // Act + $mapFromWithMapper->mapProperty('name', $source, $destination); + + // Assert + $this->assertInstanceOf(Source::class, $destination->name); + } +} diff --git a/test/MappingOperation/Implementations/MapToTest.php b/test/MappingOperation/Implementations/MapToTest.php index d07c5ea..24e9f11 100644 --- a/test/MappingOperation/Implementations/MapToTest.php +++ b/test/MappingOperation/Implementations/MapToTest.php @@ -5,8 +5,8 @@ use AutoMapperPlus\AutoMapper; use AutoMapperPlus\Configuration\AutoMapperConfigInterface; use AutoMapperPlus\Configuration\Options; +use AutoMapperPlus\NameResolver\CallbackNameResolver; use PHPUnit\Framework\TestCase; -use AutoMapperPlus\Test\Models\Nested\ChildClass; use AutoMapperPlus\Test\Models\Nested\ParentClass; use AutoMapperPlus\Test\Models\Nested\ParentClassDto; use AutoMapperPlus\Test\Models\SimpleProperties\Destination; @@ -69,4 +69,33 @@ public function testItCanMapMultiple() $this->assertEquals('SourceName1', $parentDestination->child[0]->name); $this->assertInstanceOf(Destination::class, $parentDestination->child[1]); } + + /** + * Ensure the operation uses the assigned name resolver. See #17. + */ + public function testItUsesTheNameResolver() + { + $mapTo = new MapTo(Destination::class); + $options = Options::default(); + // Set a name resolver to always use the property 'child' of the source. + $options->setNameResolver(new CallbackNameResolver(function () { + return 'child'; + })); + $mapTo->setOptions($options); + $mapTo->setMapper(AutoMapper::initialize(function (AutoMapperConfigInterface $config) { + $config->registerMapping(Source::class, Destination::class); + })); + + $parent = new ParentClass(); + $child = new Source('SourceName'); + $parent->child = $child; + $parentDestination = new ParentClassDto(); + + $mapTo->mapProperty('anotherProperty', $parent, $parentDestination); + + // Because of the name resolver, we expect the value to be set + // correctly. + $this->assertInstanceOf(Destination::class, $parentDestination->anotherProperty); + $this->assertEquals('SourceName', $parentDestination->anotherProperty->name); + } } diff --git a/test/Models/Nested/Address.php b/test/Models/Nested/Address.php new file mode 100644 index 0000000..722cc20 --- /dev/null +++ b/test/Models/Nested/Address.php @@ -0,0 +1,9 @@ +