Skip to content

Commit

Permalink
Merge pull request #25 from nikophil/feat/custom-transformer-multi-fi…
Browse files Browse the repository at this point in the history
…elds

feat: pass full input object to property custom transformers
  • Loading branch information
nikophil authored Jan 12, 2024
2 parents d02c3b6 + 69b37b4 commit 7f532d2
Show file tree
Hide file tree
Showing 28 changed files with 371 additions and 134 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]
### Added
- [GH#25](https://github.com/jolicode/automapper/pull/25) Pass full input object to property custom transformers
- [GH#10](https://github.com/jolicode/automapper/pull/10) Introduce custom transformers
- [GH#26](https://github.com/jolicode/automapper/pull/26) Fix mappings involving DateTimeInterface type

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"php": "^8.2",
"doctrine/inflector": "^1.4 || ^2.0",
"nikic/php-parser": "^4.0",
"symfony/deprecation-contracts": "^2.2|^3.0",
"symfony/property-info": "^5.4 || ^6.0 || ^7.0",
"symfony/serializer": "^5.4 || ^6.0 || ^7.0"
},
Expand Down
69 changes: 68 additions & 1 deletion docs/homepage.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,71 @@ dump($automapper->map(new InputUser('John', 'Doe', 28), DatabaseUser::class));
// +lastName: "Doe"
// +age: 28
// }
```
```

### How to customize the mapping? 🚀

The mapping process could be extended in multiple ways.

#### Map manually a single property

You can override the mapping of a single property by leveraging `AutoMapper\Transformer\CustomTransformer\CustomPropertyTransformerInterface`.
It can be useful if you need to map several properties from the source to a unique property in the target.

```php
class BirthDateUserTransformer implements CustomPropertyTransformerInterface
{
public function supports(string $source, string $target, string $propertyName): bool
{
return $source === InputUser::class && $target === DatabaseUser::class && $propertyName === 'birthDate';
}

/**
* @param InputUser $source
*/
public function transform(object $source): \DateTimeImmutable
{
return new \DateTimeImmutable("{$source->birthYear}-{$source->birthMonth}-{$source->birthDay}");
}
}
```

#### Map manually a whole object

In order to customize the mapping of a whole object, you can leverage `AutoMapper\Transformer\CustomTransformer\CustomModelTransformerInterface`.
You have then full control over the transformation between two types:

```php
use Symfony\Component\PropertyInfo\Type;

class InputUserToDatabaseUserCustomTransformer implements CustomModelTransformerInterface
{
public function supports(array $sourceTypes, array $targetTypes): bool
{
return $this->hasType($sourceTypes, DatabaseUser::class) && $this->hasType($targetTypes, OutputUser::class);
}

/**
* @param DatabaseUser $source
*/
public function transform(object $source): OutputUser
{
return OutputUser::fromDatabaserUser($source);
}

/**
* @param Type[] $types
* @param class-string $class
*/
private function hasType(array $types, string $class): bool
{
foreach ($types as $type) {
if ($type->getClassName() === $class) {
return true;
}
}

return false;
}
}
```
2 changes: 1 addition & 1 deletion docs/navigation.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
- [Quick start](/)
- [Symfony](/symfony)
- [Contributing](/contributing)
- [Contributing](/contributing)
3 changes: 2 additions & 1 deletion src/AutoMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use AutoMapper\Exception\NoMappingFoundException;
use AutoMapper\Extractor\ClassMethodToCallbackExtractor;
use AutoMapper\Extractor\CustomTransformerExtractor;
use AutoMapper\Extractor\FromSourceMappingExtractor;
use AutoMapper\Extractor\FromTargetMappingExtractor;
use AutoMapper\Extractor\MapToContextPropertyInfoExtractorDecorator;
Expand Down Expand Up @@ -178,7 +179,7 @@ public static function create(
if (null === $loader) {
$loader = new EvalLoader(
new Generator(
new ClassMethodToCallbackExtractor(),
new CustomTransformerExtractor(new ClassMethodToCallbackExtractor()),
(new ParserFactory())->create(ParserFactory::PREFER_PHP7),
new ClassDiscriminatorFromClassMetadata($classMetadataFactory),
$allowReadOnlyTargetToPopulate
Expand Down
5 changes: 2 additions & 3 deletions src/Extractor/ClassMethodToCallbackExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use AutoMapper\Exception\RuntimeException;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt;
use PhpParser\Parser;
Expand Down Expand Up @@ -65,8 +64,8 @@ public function extract(string $class, string $method, array $inputParameters):

$closureParameters = [];
foreach ($classMethod->getParams() as $parameter) {
if ($parameter->var instanceof Expr\Variable && $parameter->type instanceof Identifier) {
$closureParameters[] = new Param(new Expr\Variable($parameter->var->name), type: $parameter->type->name);
if ($parameter->var instanceof Expr\Variable) {
$closureParameters[] = new Param(new Expr\Variable($parameter->var->name), type: $parameter->type);
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/Extractor/CustomTransformerExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Extractor;

use AutoMapper\Transformer\CustomTransformer\CustomModelTransformerInterface;
use AutoMapper\Transformer\CustomTransformer\CustomTransformerInterface;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;

final readonly class CustomTransformerExtractor
{
public function __construct(
private ClassMethodToCallbackExtractor $extractor
) {
}

/**
* @param class-string<CustomTransformerInterface> $customTransformerClass
*/
public function extract(string $customTransformerClass, Expr|null $propertyToTransform, Expr $sourceObject): Expr
{
if (!$propertyToTransform && is_a($customTransformerClass, CustomModelTransformerInterface::class, allow_string: true)) {
throw new \LogicException('CustomModelTransformerInterface must use $propertyToTransform.');
}

$arg = is_a($customTransformerClass, CustomModelTransformerInterface::class, allow_string: true)
? $propertyToTransform
// let's pass the full object when using "property" custom transform for more flexibility
: $sourceObject;

return $this->extractor->extract(
$customTransformerClass,
'transform',
[new Arg($arg)]
);
}
}
25 changes: 12 additions & 13 deletions src/Extractor/MappingExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,18 @@ public function getWriteMutator(string $source, string $target, string $property
return new WriteMutator(WriteMutator::TYPE_CONSTRUCTOR, $writeInfo->getName(), false, $parameter);
}

// The reported WriteInfo of readonly promoted properties is incorrectly returned as a writeable property when constructor extraction is disabled.
// see https://github.com/symfony/symfony/pull/48108
if (
($context['enable_constructor_extraction'] ?? true) === false
&& \PHP_VERSION_ID >= 80100
&& PropertyWriteInfo::TYPE_PROPERTY === $writeInfo->getType()
) {
$reflectionProperty = new \ReflectionProperty($target, $property);

if ($reflectionProperty->isReadOnly() || $reflectionProperty->isPromoted()) {
return null;
}
}
// // The reported WriteInfo of readonly promoted properties is incorrectly returned as a writeable property when constructor extraction is disabled.
// // see https://github.com/symfony/symfony/pull/48108
// if (
// ($context['enable_constructor_extraction'] ?? true) === false
// && PropertyWriteInfo::TYPE_PROPERTY === $writeInfo->getType()
// ) {
// $reflectionProperty = new \ReflectionProperty($target, $property);
//
// if ($reflectionProperty->isReadOnly() || $reflectionProperty->isPromoted()) {
// return null;
// }
// }

$type = WriteMutator::TYPE_PROPERTY;

Expand Down
13 changes: 12 additions & 1 deletion src/Extractor/PropertyMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
final class PropertyMapping
{
public function __construct(
public readonly ReadAccessor $readAccessor,
public readonly ?ReadAccessor $readAccessor,
public readonly ?WriteMutator $writeMutator,
public readonly ?WriteMutator $writeMutatorConstructor,
/** @var TransformerInterface|class-string<CustomTransformerInterface> */
Expand All @@ -37,4 +37,15 @@ public function shouldIgnoreProperty(bool $shouldMapPrivateProperties = true): b
|| $this->targetIgnored
|| !($shouldMapPrivateProperties || $this->isPublic);
}

/**
* @phpstan-assert-if-false TransformerInterface $this->transformer
* @phpstan-assert-if-false !null $this->readAccessor
*
* @phpstan-assert-if-true string $this->transformer
*/
public function hasCustomTransformer(): bool
{
return \is_string($this->transformer);
}
}
126 changes: 76 additions & 50 deletions src/Extractor/SourceTargetMappingExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,59 +33,85 @@ public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): a
continue;
}

if (\in_array($property, $targetProperties, true)) {
$targetMutatorConstruct = $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [
'enable_constructor_extraction' => true,
]);

if ((null === $targetMutatorConstruct || null === $targetMutatorConstruct->parameter) && !$this->propertyInfoExtractor->isWritable($mapperMetadata->getTarget(), $property)) {
continue;
}

$sourceTypes = $this->propertyInfoExtractor->getTypes($mapperMetadata->getSource(), $property) ?? [];
$targetTypes = $this->propertyInfoExtractor->getTypes($mapperMetadata->getTarget(), $property) ?? [];

$transformer = $this->customTransformerRegistry->getCustomTransformerClass($mapperMetadata, $sourceTypes, $targetTypes, $property)
?? $this->transformerFactory->getTransformer($sourceTypes, $targetTypes, $mapperMetadata);

if (null === $transformer) {
continue;
}

$sourceAccessor = $this->getReadAccessor($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property);
$targetMutator = $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [
'enable_constructor_extraction' => false,
]);

$maxDepthSource = $this->getMaxDepth($mapperMetadata->getSource(), $property);
$maxDepthTarget = $this->getMaxDepth($mapperMetadata->getTarget(), $property);
$maxDepth = null;

if (null !== $maxDepthSource && null !== $maxDepthTarget) {
$maxDepth = min($maxDepthSource, $maxDepthTarget);
} elseif (null !== $maxDepthSource) {
$maxDepth = $maxDepthSource;
} elseif (null !== $maxDepthTarget) {
$maxDepth = $maxDepthTarget;
}

$mapping[] = new PropertyMapping(
$sourceAccessor,
$targetMutator,
WriteMutator::TYPE_CONSTRUCTOR === $targetMutatorConstruct->type ? $targetMutatorConstruct : null,
$transformer,
$property,
false,
$this->getGroups($mapperMetadata->getSource(), $property),
$this->getGroups($mapperMetadata->getTarget(), $property),
$maxDepth,
$this->isIgnoredProperty($mapperMetadata->getSource(), $property),
$this->isIgnoredProperty($mapperMetadata->getTarget(), $property),
PropertyReadInfo::VISIBILITY_PUBLIC === ($this->readInfoExtractor->getReadInfo($mapperMetadata->getSource(), $property)?->getVisibility() ?? PropertyReadInfo::VISIBILITY_PUBLIC),
);
if (!\in_array($property, $targetProperties, true)) {
continue;
}

if ($propertyMapping = $this->toPropertyMapping($mapperMetadata, $property)) {
$mapping[] = $propertyMapping;
}
}

// let's loop over target properties which are not automatically mapped to a source property:
// this would eventually allow finding custom transformers which only operate on target properties
foreach ($targetProperties as $property) {
if (!$this->propertyInfoExtractor->isWritable($mapperMetadata->getTarget(), $property)) {
continue;
}

if (\in_array($property, $sourceProperties, true)) {
continue;
}

if ($propertyMapping = $this->toPropertyMapping($mapperMetadata, $property, onlyCustomTransformer: true)) {
$mapping[] = $propertyMapping;
}
}

return $mapping;
}

private function toPropertyMapping(MapperMetadataInterface $mapperMetadata, string $property, bool $onlyCustomTransformer = false): PropertyMapping|null
{
$targetMutatorConstruct = $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [
'enable_constructor_extraction' => true,
]);

if ((null === $targetMutatorConstruct || null === $targetMutatorConstruct->parameter) && !$this->propertyInfoExtractor->isWritable($mapperMetadata->getTarget(), $property)) {
return null;
}

$sourceTypes = $this->propertyInfoExtractor->getTypes($mapperMetadata->getSource(), $property) ?? [];
$targetTypes = $this->propertyInfoExtractor->getTypes($mapperMetadata->getTarget(), $property) ?? [];

$transformer = $this->customTransformerRegistry->getCustomTransformerClass($mapperMetadata, $sourceTypes, $targetTypes, $property);

if (null === $transformer && !$onlyCustomTransformer) {
$transformer = $this->transformerFactory->getTransformer($sourceTypes, $targetTypes, $mapperMetadata);
}

if (null === $transformer) {
return null;
}

return new PropertyMapping(
readAccessor: $this->getReadAccessor($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property),
writeMutator: $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [
'enable_constructor_extraction' => false,
]),
writeMutatorConstructor: WriteMutator::TYPE_CONSTRUCTOR === $targetMutatorConstruct->type ? $targetMutatorConstruct : null,
transformer: $transformer,
property: $property,
checkExists: false,
sourceGroups: $this->getGroups($mapperMetadata->getSource(), $property),
targetGroups: $this->getGroups($mapperMetadata->getTarget(), $property),
maxDepth: $this->guessMaxDepth($mapperMetadata, $property),
sourceIgnored: $this->isIgnoredProperty($mapperMetadata->getSource(), $property),
targetIgnored: $this->isIgnoredProperty($mapperMetadata->getTarget(), $property),
isPublic: PropertyReadInfo::VISIBILITY_PUBLIC === ($this->readInfoExtractor->getReadInfo($mapperMetadata->getSource(), $property)?->getVisibility() ?? PropertyReadInfo::VISIBILITY_PUBLIC),
);
}

private function guessMaxDepth(MapperMetadataInterface $mapperMetadata, string $property): int|null
{
$maxDepthSource = $this->getMaxDepth($mapperMetadata->getSource(), $property);
$maxDepthTarget = $this->getMaxDepth($mapperMetadata->getTarget(), $property);

return match (true) {
null !== $maxDepthSource && null !== $maxDepthTarget => min($maxDepthSource, $maxDepthTarget),
null !== $maxDepthSource => $maxDepthSource,
null !== $maxDepthTarget => $maxDepthTarget,
default => null
};
}
}
Loading

0 comments on commit 7f532d2

Please sign in to comment.