diff --git a/CHANGELOG.md b/CHANGELOG.md index 0266e5d2..593f669c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [GH#27](https://github.com/jolicode/automapper/pull/27) Use PhpStanExtractor instead of PhpDocExtractor +- [GH#35](https://github.com/jolicode/automapper/pull/35) Refactoring Mapper Generator ## [8.1.0] - 2023-12-14 ### Added diff --git a/src/AutoMapper.php b/src/AutoMapper.php index 2974d95d..8b81b09a 100644 --- a/src/AutoMapper.php +++ b/src/AutoMapper.php @@ -11,7 +11,8 @@ use AutoMapper\Extractor\FromTargetMappingExtractor; use AutoMapper\Extractor\MapToContextPropertyInfoExtractorDecorator; use AutoMapper\Extractor\SourceTargetMappingExtractor; -use AutoMapper\Generator\Generator; +use AutoMapper\Generator\MapperGenerator; +use AutoMapper\Generator\Shared\ClassDiscriminatorResolver; use AutoMapper\Loader\ClassLoaderInterface; use AutoMapper\Loader\EvalLoader; use AutoMapper\Transformer\ArrayTransformerFactory; @@ -28,7 +29,6 @@ use AutoMapper\Transformer\TransformerFactoryInterface; use AutoMapper\Transformer\UniqueTypeTransformerFactory; use Doctrine\Common\Annotations\AnnotationReader; -use PhpParser\ParserFactory; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; @@ -178,13 +178,11 @@ public static function create( if (null === $loader) { $loader = new EvalLoader( - new Generator( + new MapperGenerator( new CustomTransformerExtractor(new ClassMethodToCallbackExtractor()), - (new ParserFactory())->create(ParserFactory::PREFER_PHP7), - new ClassDiscriminatorFromClassMetadata($classMetadataFactory), + new ClassDiscriminatorResolver(new ClassDiscriminatorFromClassMetadata($classMetadataFactory)), $allowReadOnlyTargetToPopulate - ) - ); + )); } $flags = ReflectionExtractor::ALLOW_PUBLIC | ReflectionExtractor::ALLOW_PROTECTED | ReflectionExtractor::ALLOW_PRIVATE; diff --git a/src/Extractor/CustomTransformerExtractor.php b/src/Extractor/CustomTransformerExtractor.php index 21383ac1..82abe50d 100644 --- a/src/Extractor/CustomTransformerExtractor.php +++ b/src/Extractor/CustomTransformerExtractor.php @@ -9,6 +9,9 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr; +/** + * @internal + */ final readonly class CustomTransformerExtractor { public function __construct( diff --git a/src/Extractor/FromSourceMappingExtractor.php b/src/Extractor/FromSourceMappingExtractor.php index 4990bcd6..8a146cc9 100644 --- a/src/Extractor/FromSourceMappingExtractor.php +++ b/src/Extractor/FromSourceMappingExtractor.php @@ -5,7 +5,7 @@ namespace AutoMapper\Extractor; use AutoMapper\Exception\InvalidMappingException; -use AutoMapper\MapperMetadataInterface; +use AutoMapper\MapperGeneratorMetadataInterface; use AutoMapper\Transformer\CustomTransformer\CustomTransformersRegistry; use AutoMapper\Transformer\TransformerFactoryInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -22,6 +22,8 @@ * Can use a NameConverter to use specific properties name in the target * * @author Joel Wurtz + * + * @internal */ final class FromSourceMappingExtractor extends MappingExtractor { @@ -39,7 +41,7 @@ public function __construct( parent::__construct($propertyInfoExtractor, $readInfoExtractor, $writeInfoExtractor, $transformerFactory, $customTransformerRegistry, $classMetadataFactory); } - public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): array + public function getPropertiesMapping(MapperGeneratorMetadataInterface $mapperMetadata): array { $sourceProperties = $this->propertyInfoExtractor->getProperties($mapperMetadata->getSource()); @@ -83,6 +85,7 @@ public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): a } $mapping[] = new PropertyMapping( + $mapperMetadata, $this->getReadAccessor($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property), $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property), null, diff --git a/src/Extractor/FromTargetMappingExtractor.php b/src/Extractor/FromTargetMappingExtractor.php index f1dbc955..cee2f6e1 100644 --- a/src/Extractor/FromTargetMappingExtractor.php +++ b/src/Extractor/FromTargetMappingExtractor.php @@ -5,7 +5,7 @@ namespace AutoMapper\Extractor; use AutoMapper\Exception\InvalidMappingException; -use AutoMapper\MapperMetadataInterface; +use AutoMapper\MapperGeneratorMetadataInterface; use AutoMapper\Transformer\CustomTransformer\CustomTransformersRegistry; use AutoMapper\Transformer\TransformerFactoryInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -23,6 +23,8 @@ * Can use a NameConverter to use specific properties name in the source * * @author Joel Wurtz + * + * @internal */ final class FromTargetMappingExtractor extends MappingExtractor { @@ -40,7 +42,7 @@ public function __construct( parent::__construct($propertyInfoExtractor, $readInfoExtractor, $writeInfoExtractor, $transformerFactory, $customTransformerRegistry, $classMetadataFactory); } - public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): array + public function getPropertiesMapping(MapperGeneratorMetadataInterface $mapperMetadata): array { $targetProperties = array_unique($this->propertyInfoExtractor->getProperties($mapperMetadata->getTarget()) ?? []); @@ -78,6 +80,7 @@ public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): a } $mapping[] = new PropertyMapping( + $mapperMetadata, $this->getReadAccessor($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property), $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [ 'enable_constructor_extraction' => false, diff --git a/src/Extractor/MapToContextPropertyInfoExtractorDecorator.php b/src/Extractor/MapToContextPropertyInfoExtractorDecorator.php index 145d417e..94bfadd7 100644 --- a/src/Extractor/MapToContextPropertyInfoExtractorDecorator.php +++ b/src/Extractor/MapToContextPropertyInfoExtractorDecorator.php @@ -16,6 +16,9 @@ use Symfony\Component\PropertyInfo\PropertyWriteInfo; use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; +/** + * @internal + */ final readonly class MapToContextPropertyInfoExtractorDecorator implements PropertyListExtractorInterface, PropertyTypeExtractorInterface, PropertyAccessExtractorInterface, PropertyInitializableExtractorInterface, PropertyReadInfoExtractorInterface, PropertyWriteInfoExtractorInterface, ConstructorArgumentTypeExtractorInterface { public function __construct( diff --git a/src/Extractor/MappingExtractorInterface.php b/src/Extractor/MappingExtractorInterface.php index 098c3d5a..48eb6f97 100644 --- a/src/Extractor/MappingExtractorInterface.php +++ b/src/Extractor/MappingExtractorInterface.php @@ -4,7 +4,7 @@ namespace AutoMapper\Extractor; -use AutoMapper\MapperMetadataInterface; +use AutoMapper\MapperGeneratorMetadataInterface; /** * Extracts mapping. @@ -20,7 +20,7 @@ interface MappingExtractorInterface * * @return PropertyMapping[] */ - public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): array; + public function getPropertiesMapping(MapperGeneratorMetadataInterface $mapperMetadata): array; /** * Extracts read accessor for a given source, target and property. diff --git a/src/Extractor/PropertyMapping.php b/src/Extractor/PropertyMapping.php index e1039b58..81e43f47 100644 --- a/src/Extractor/PropertyMapping.php +++ b/src/Extractor/PropertyMapping.php @@ -4,6 +4,7 @@ namespace AutoMapper\Extractor; +use AutoMapper\MapperGeneratorMetadataInterface; use AutoMapper\Transformer\CustomTransformer\CustomTransformerInterface; use AutoMapper\Transformer\TransformerInterface; @@ -11,36 +12,39 @@ * Property mapping. * * @author Joel Wurtz + * + * @internal */ -final class PropertyMapping +final readonly class PropertyMapping { public function __construct( - public readonly ?ReadAccessor $readAccessor, - public readonly ?WriteMutator $writeMutator, - public readonly ?WriteMutator $writeMutatorConstructor, + public MapperGeneratorMetadataInterface $mapperMetadata, + public ?ReadAccessor $readAccessor, + public ?WriteMutator $writeMutator, + public ?WriteMutator $writeMutatorConstructor, /** @var TransformerInterface|class-string */ - public readonly TransformerInterface|string $transformer, - public readonly string $property, - public readonly bool $checkExists = false, - public readonly ?array $sourceGroups = null, - public readonly ?array $targetGroups = null, - public readonly ?int $maxDepth = null, - public readonly bool $sourceIgnored = false, - public readonly bool $targetIgnored = false, - public readonly bool $isPublic = false, + public TransformerInterface|string $transformer, + public string $property, + public bool $checkExists = false, + public ?array $sourceGroups = null, + public ?array $targetGroups = null, + public ?int $maxDepth = null, + public bool $sourceIgnored = false, + public bool $targetIgnored = false, + public bool $isPublic = false, ) { } public function shouldIgnoreProperty(bool $shouldMapPrivateProperties = true): bool { - return $this->sourceIgnored + return !$this->writeMutator + || $this->sourceIgnored || $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 */ diff --git a/src/Extractor/ReadAccessor.php b/src/Extractor/ReadAccessor.php index d8c77de0..4d99ebe5 100644 --- a/src/Extractor/ReadAccessor.php +++ b/src/Extractor/ReadAccessor.php @@ -18,6 +18,8 @@ * Read accessor tell how to read from a property. * * @author Joel Wurtz + * + * @internal */ final class ReadAccessor { diff --git a/src/Extractor/SourceTargetMappingExtractor.php b/src/Extractor/SourceTargetMappingExtractor.php index 815191bc..0cb77bd1 100644 --- a/src/Extractor/SourceTargetMappingExtractor.php +++ b/src/Extractor/SourceTargetMappingExtractor.php @@ -4,6 +4,7 @@ namespace AutoMapper\Extractor; +use AutoMapper\MapperGeneratorMetadataInterface; use AutoMapper\MapperMetadataInterface; use Symfony\Component\PropertyInfo\PropertyReadInfo; @@ -11,10 +12,12 @@ * Extracts mapping between two objects, only gives properties that have the same name. * * @author Joel Wurtz + * + * @internal */ class SourceTargetMappingExtractor extends MappingExtractor { - public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): array + public function getPropertiesMapping(MapperGeneratorMetadataInterface $mapperMetadata): array { $sourceProperties = $this->propertyInfoExtractor->getProperties($mapperMetadata->getSource()); $targetProperties = $this->propertyInfoExtractor->getProperties($mapperMetadata->getTarget()); @@ -61,7 +64,7 @@ public function getPropertiesMapping(MapperMetadataInterface $mapperMetadata): a return $mapping; } - private function toPropertyMapping(MapperMetadataInterface $mapperMetadata, string $property, bool $onlyCustomTransformer = false): PropertyMapping|null + private function toPropertyMapping(MapperGeneratorMetadataInterface $mapperMetadata, string $property, bool $onlyCustomTransformer = false): PropertyMapping|null { $targetMutatorConstruct = $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [ 'enable_constructor_extraction' => true, @@ -85,6 +88,7 @@ private function toPropertyMapping(MapperMetadataInterface $mapperMetadata, stri } return new PropertyMapping( + $mapperMetadata, readAccessor: $this->getReadAccessor($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property), writeMutator: $this->getWriteMutator($mapperMetadata->getSource(), $mapperMetadata->getTarget(), $property, [ 'enable_constructor_extraction' => false, diff --git a/src/Extractor/WriteMutator.php b/src/Extractor/WriteMutator.php index e9f5692d..351758f5 100644 --- a/src/Extractor/WriteMutator.php +++ b/src/Extractor/WriteMutator.php @@ -16,6 +16,8 @@ * Writes mutator tell how to write to a property. * * @author Joel Wurtz + * + * @internal */ final class WriteMutator { diff --git a/src/Generator/CreateTargetStatementsGenerator.php b/src/Generator/CreateTargetStatementsGenerator.php new file mode 100644 index 00000000..267d2e20 --- /dev/null +++ b/src/Generator/CreateTargetStatementsGenerator.php @@ -0,0 +1,334 @@ +parser = $parser ?? (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + } + + /** + * If the result is null, we create the object. + * + * ```php + * if (null === $result) { + * ... // create object statements + * } + * ``` + */ + public function generate(MapperGeneratorMetadataInterface $mapperMetadata, VariableRegistry $variableRegistry): Stmt + { + $createObjectStatements = []; + + $createObjectStatements[] = $this->targetAsArray($mapperMetadata); + $createObjectStatements[] = $this->sourceAndTargetAsStdClass($mapperMetadata); + $createObjectStatements[] = $this->targetAsStdClass($mapperMetadata); + $createObjectStatements = [...$createObjectStatements, ...$this->discriminatorStatementsGenerator->createTargetStatements($mapperMetadata)]; + $createObjectStatements = [...$createObjectStatements, ...$this->constructorArguments($mapperMetadata)]; + $createObjectStatements[] = $this->cachedReflectionStatementsGenerator->createTargetStatement($mapperMetadata); + $createObjectStatements[] = $this->constructorWithoutArgument($mapperMetadata); + + $createObjectStatements = array_values(array_filter($createObjectStatements)); + + return new Stmt\If_(new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), $variableRegistry->getResult()), [ + 'stmts' => $createObjectStatements, + ]); + } + + private function targetAsArray(MapperGeneratorMetadataInterface $mapperMetadata): Stmt|null + { + if ($mapperMetadata->getTarget() !== 'array') { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return new Stmt\Expression(new Expr\Assign($variableRegistry->getResult(), new Expr\Array_())); + } + + private function sourceAndTargetAsStdClass(MapperGeneratorMetadataInterface $mapperMetadata): Stmt|null + { + if (\stdClass::class !== $mapperMetadata->getSource() || \stdClass::class !== $mapperMetadata->getTarget()) { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return new Stmt\Expression( + new Expr\Assign( + $variableRegistry->getResult(), + new Expr\FuncCall( + new Name('unserialize'), + [new Arg(new Expr\FuncCall(new Name('serialize'), [new Arg($variableRegistry->getSourceInput())]))] + ) + ) + ); + } + + private function targetAsStdClass(MapperGeneratorMetadataInterface $mapperMetadata): Stmt|null + { + if (\stdClass::class === $mapperMetadata->getSource() || \stdClass::class !== $mapperMetadata->getTarget()) { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return new Stmt\Expression(new Expr\Assign($variableRegistry->getResult(), new Expr\New_(new Name(\stdClass::class)))); + } + + /** + * @return list + */ + private function constructorArguments(MapperGeneratorMetadataInterface $mapperMetadata): array + { + if (!$mapperMetadata->targetIsAUserDefinedClass()) { + return []; + } + + $targetConstructor = $mapperMetadata->getCachedTargetReflectionClass()?->getConstructor(); + + if (!$targetConstructor || !$mapperMetadata->hasConstructor()) { + return []; + } + + $constructArguments = []; + $createObjectStatements = []; + + foreach ($mapperMetadata->getPropertiesMapping() as $propertyMapping) { + /* + * This is the main loop to map the properties from the source to the target in the constructor, there is 2 main steps in order to generated this code : + * + * * Generate code on how to read the value from the source, which returns statements and an output expression + * * Generate code on how to transform the value, which use the output expression, add some statements and return a new output expression + * + * As an example this could generate the following code : + * + * * Extract value from a private property : $this->extractCallbacks['propertyName']($source) + * * Transform the value, which is an object in this example, with another mapper : $this->mappers['SOURCE_TO_TARGET_MAPPER']->map(..., $context); + * + * The output expression of the transform will then be used as argument for the object constructor + * + * $constructArg1 = $this->mappers['SOURCE_TO_TARGET_MAPPER']->map($this->extractCallbacks['propertyName']($source), $context); + * $result = new Foo($constructArg1); + */ + $constructorArgumentResult = $this->constructorArgument($propertyMapping); + + if (!$constructorArgumentResult) { + continue; + } + + [$createObjectStatement, $constructArgument, $constructorPosition] = $constructorArgumentResult; + + $createObjectStatements[] = $createObjectStatement; + $constructArguments[$constructorPosition] = $constructArgument; + } + + /* We loop to get constructor arguments that were not present in the source */ + foreach ($targetConstructor->getParameters() as $constructorParameter) { + if (\array_key_exists($constructorParameter->getPosition(), $constructArguments) && $constructorParameter->isDefaultValueAvailable()) { + continue; + } + + [$createObjectStatement, $constructArgument, $constructorPosition] = $this->constructorArgumentWithDefaultValue($mapperMetadata, $constructArguments, $constructorParameter) ?? [null, null, null]; + + if (!$createObjectStatement) { + continue; + } + + $createObjectStatements[] = $createObjectStatement; + $constructArguments[$constructorPosition] = $constructArgument; + } + + ksort($constructArguments); + + /* + * Create object with the constructor arguments + * + * $result = new Foo($constructArg1, $constructArg2, ...); + */ + $createObjectStatements[] = new Stmt\Expression( + new Expr\Assign( + $mapperMetadata->getVariableRegistry()->getResult(), + new Expr\New_(new Name\FullyQualified($mapperMetadata->getTarget()), $constructArguments) + ) + ); + + return $createObjectStatements; + } + + /** + * Check if there is a constructor argument in the context, otherwise we use the transformed value. + * + * ```php + * if (MapperContext::hasConstructorArgument($context, $target, 'propertyName')) { + * $constructArg1 = MapperContext::getConstructorArgument($context, $target, 'propertyName'); + * } else { + * $constructArg1 = $source->propertyName; + * } + * ``` + * + * @return array{Stmt, Arg, int}|null + */ + private function constructorArgument(PropertyMapping $propertyMapping): array|null + { + if (null === $propertyMapping->writeMutatorConstructor || null === ($parameter = $propertyMapping->writeMutatorConstructor->parameter)) { + return null; + } + + $mapperMetadata = $propertyMapping->mapperMetadata; + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + $constructVar = $variableRegistry->getVariableWithUniqueName('constructArg'); + + if ($propertyMapping->hasCustomTransformer()) { + $propStatements = []; + /* + * let's extract custom transformer's transform() method in a closure, + * and add it as a constructor argument + */ + $output = $this->customTransformerExtractor->extract( + $propertyMapping->transformer, + $propertyMapping->readAccessor?->getExpression($variableRegistry->getSourceInput()), + $variableRegistry->getSourceInput() + ); + } elseif ($propertyMapping->readAccessor) { + $fieldValueExpr = $propertyMapping->readAccessor->getExpression($variableRegistry->getSourceInput()); + + /* Get extract and transform statements for this property */ + [$output, $propStatements] = $propertyMapping->transformer->transform($fieldValueExpr, $constructVar, $propertyMapping, $variableRegistry->getUniqueVariableScope()); + } else { + return null; + } + + $propStatements[] = new Stmt\Expression(new Expr\Assign($constructVar, $output)); + + return [ + new Stmt\If_(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'hasConstructorArgument', [ + new Arg($variableRegistry->getContext()), + new Arg(new Scalar\String_($mapperMetadata->getTarget())), + new Arg(new Scalar\String_($propertyMapping->property)), + ]), [ + 'stmts' => [ + new Stmt\Expression(new Expr\Assign($constructVar, new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'getConstructorArgument', [ + new Arg($variableRegistry->getContext()), + new Arg(new Scalar\String_($mapperMetadata->getTarget())), + new Arg(new Scalar\String_($propertyMapping->property)), + ]))), + ], + 'else' => new Stmt\Else_($propStatements), + ]), + new Arg($constructVar), + $parameter->getPosition(), + ]; + } + + /** + * Check if there is a constructor argument in the context, otherwise we use the default value. + * + * ``` + * if (MapperContext::hasConstructorArgument($context, $target, 'propertyName')) { + * $constructArg2 = MapperContext::getConstructorArgument($context, $target, 'propertyName'); + * } else { + * $constructArg2 = 'default value'; + * } + * ``` + * + * @param Arg[] $constructArguments + * + * @return array{Stmt, Arg, int}|null + */ + private function constructorArgumentWithDefaultValue(MapperGeneratorMetadataInterface $mapperMetadata, array $constructArguments, \ReflectionParameter $constructorParameter): array|null + { + if (\array_key_exists($constructorParameter->getPosition(), $constructArguments) || !$constructorParameter->isDefaultValueAvailable()) { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + $constructVar = $variableRegistry->getVariableWithUniqueName('constructArg'); + + return [ + new Stmt\If_(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'hasConstructorArgument', [ + new Arg($variableRegistry->getContext()), + new Arg(new Scalar\String_($mapperMetadata->getTarget())), + new Arg(new Scalar\String_($constructorParameter->getName())), + ]), [ + 'stmts' => [ + new Stmt\Expression(new Expr\Assign($constructVar, new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'getConstructorArgument', [ + new Arg($variableRegistry->getContext()), + new Arg(new Scalar\String_($mapperMetadata->getTarget())), + new Arg(new Scalar\String_($constructorParameter->getName())), + ]))), + ], + 'else' => new Stmt\Else_([ + new Stmt\Expression(new Expr\Assign($constructVar, $this->getValueAsExpr($constructorParameter->getDefaultValue()))), + ]), + ]), + new Arg($constructVar), + $constructorParameter->getPosition(), + ]; + } + + /** + * Create object with constructor (which have no arguments). + * + * ```php + * $result = new Foo(); + * ``` + */ + private function constructorWithoutArgument(MapperGeneratorMetadataInterface $mapperMetadata): Stmt|null + { + if (!$mapperMetadata->targetIsAUserDefinedClass() + ) { + return null; + } + + $targetConstructor = $mapperMetadata->getCachedTargetReflectionClass()?->getConstructor(); + + if ($targetConstructor) { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return new Stmt\Expression(new Expr\Assign($variableRegistry->getResult(), new Expr\New_(new Name\FullyQualified($mapperMetadata->getTarget())))); + } + + private function getValueAsExpr(mixed $value): Expr + { + $expr = $this->parser->parse('expr; + } + + throw new \LogicException('Cannot extract expr from ' . var_export($value, true)); + } +} diff --git a/src/Generator/Generator.php b/src/Generator/Generator.php deleted file mode 100644 index 637909bc..00000000 --- a/src/Generator/Generator.php +++ /dev/null @@ -1,804 +0,0 @@ - - */ -final readonly class Generator -{ - private Parser $parser; - - public function __construct( - private CustomTransformerExtractor $customTransformerExtractor, - ?Parser $parser = null, - private ?ClassDiscriminatorResolverInterface $classDiscriminator = null, - private bool $allowReadOnlyTargetToPopulate = false, - ) { - $this->parser = $parser ?? (new ParserFactory())->create(ParserFactory::PREFER_PHP7); - } - - /** - * Generate Class AST given metadata for a mapper. - * - * @throws CompileException - */ - public function generate(MapperGeneratorMetadataInterface $mapperGeneratorMetadata): Stmt\Class_ - { - $propertiesMapping = $mapperGeneratorMetadata->getPropertiesMapping(); - - $uniqueVariableScope = new UniqueVariableScope(); - $sourceInput = new Expr\Variable($uniqueVariableScope->getUniqueName('value')); - $result = new Expr\Variable($uniqueVariableScope->getUniqueName('result')); - $hashVariable = new Expr\Variable($uniqueVariableScope->getUniqueName('sourceHash')); - $contextVariable = new Expr\Variable($uniqueVariableScope->getUniqueName('context')); - $constructStatements = []; - $addedDependencies = []; - $canHaveCircularDependency = $mapperGeneratorMetadata->canHaveCircularReference() && 'array' !== $mapperGeneratorMetadata->getSource(); - - /** - * First statement is to check if the source is null, if so, return null. - * - * if (null === $source) { - * return $source; - * ] - */ - $statements = [ - new Stmt\If_(new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), $sourceInput), [ - 'stmts' => [new Stmt\Return_($sourceInput)], - ]), - ]; - - if ($canHaveCircularDependency) { - /* - * When there can be circular dependency in the mapping, the following statements try to use the reference for the source if it's available - * - * $sourceHash = spl_object_hash($source) . $target; - * if (MapperContext::shouldHandleCircularReference($context, $sourceHash, $source)) { - * return MapperContext::handleCircularReference($context, $sourceHash, $source, $this->circularReferenceLimit, $this->circularReferenceHandler); - * } - */ - $statements[] = new Stmt\Expression(new Expr\Assign($hashVariable, new Expr\BinaryOp\Concat(new Expr\FuncCall(new Name('spl_object_hash'), [ - new Arg($sourceInput), - ]), - new Scalar\String_($mapperGeneratorMetadata->getTarget()) - ))); - $statements[] = new Stmt\If_(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'shouldHandleCircularReference', [ - new Arg($contextVariable), - new Arg($hashVariable), - new Arg(new Expr\PropertyFetch(new Expr\Variable('this'), 'circularReferenceLimit')), - ]), [ - 'stmts' => [ - new Stmt\Return_(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'handleCircularReference', [ - new Arg($contextVariable), - new Arg($hashVariable), - new Arg($sourceInput), - new Arg(new Expr\PropertyFetch(new Expr\Variable('this'), 'circularReferenceLimit')), - new Arg(new Expr\PropertyFetch(new Expr\Variable('this'), 'circularReferenceHandler')), - ])), - ], - ]); - } - - /** - * Get statements about how to create the object. - * - * $createObjectStmts : Statements to create the object - * $inConstructor : Field to set in the constructor, this allow to transform them before the constructor is called - * $constructStatementsForCreateObjects : Additional statements to add in the constructor - * $injectMapperStatements : Additional statements to add in the injectMappers method, this allow to inject mappers for dependencies - */ - [$createObjectStmts, $inConstructor, $constructStatementsForCreateObjects, $injectMapperStatements] = $this->getCreateObjectStatements($mapperGeneratorMetadata, $result, $contextVariable, $sourceInput, $uniqueVariableScope); - $constructStatements = array_merge($constructStatements, $constructStatementsForCreateObjects); - - $targetToPopulate = new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::TARGET_TO_POPULATE)); - - /* - * Get result from context if available, otherwise set it to null - * - * $result = $context[MapperContext::TARGET_TO_POPULATE] ?? null; - */ - $statements[] = new Stmt\Expression(new Expr\Assign($result, new Expr\BinaryOp\Coalesce( - $targetToPopulate, - new Expr\ConstFetch(new Name('null')) - ))); - - if (!$this->allowReadOnlyTargetToPopulate && $mapperGeneratorMetadata->isTargetReadOnlyClass()) { - /* - * If the target is a read-only class, we throw an exception if the target is not null - * - * if ($contextVariable[MapperContext::ALLOW_READONLY_TARGET_TO_POPULATE] ?? false && is_object($targetToPopulate)) { - * throw new ReadOnlyTargetException(); - * } - */ - $statements[] = new Stmt\If_( - new Expr\BinaryOp\BooleanAnd( - new Expr\BooleanNot(new Expr\BinaryOp\Coalesce(new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::ALLOW_READONLY_TARGET_TO_POPULATE)), new Expr\ConstFetch(new Name('false')))), - new Expr\FuncCall(new Name('is_object'), [new Arg(new Expr\BinaryOp\Coalesce($targetToPopulate, new Expr\ConstFetch(new Name('null'))))]) - ), [ - 'stmts' => [new Stmt\Expression(new Expr\Throw_(new Expr\New_(new Name(ReadOnlyTargetException::class))))], - ]); - } - - /* - * If the result is null, we create the object - * - * if (null === $result) { - * ... // create object statements @see getCreateObjectStatements - * } - */ - $statements[] = new Stmt\If_(new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), $result), [ - 'stmts' => $createObjectStmts, - ]); - - foreach ($propertiesMapping as $propertyMapping) { - if (!$propertyMapping->transformer instanceof DependentTransformerInterface) { - continue; - } - - foreach ($propertyMapping->transformer->getDependencies() as $dependency) { - if (isset($addedDependencies[$dependency->name])) { - continue; - } - - /* - * If the transformer has dependencies, we inject the mappers for the dependencies - * This allows to inject mappers when creating the service instead of resolving them at runtime which is faster - * - * $this->mappers[$dependency->name] = $autoMapperRegistry->getMapper($dependency->source, $dependency->target); - */ - $injectMapperStatements[] = new Stmt\Expression(new Expr\Assign( - new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), new Scalar\String_($dependency->name)), - new Expr\MethodCall(new Expr\Variable('autoMapperRegistry'), 'getMapper', [ - new Arg(new Scalar\String_($dependency->source)), - new Arg(new Scalar\String_($dependency->target)), - ]) - )); - $addedDependencies[$dependency->name] = true; - } - } - - $addedDependenciesStatements = []; - if ($addedDependencies) { - if ($canHaveCircularDependency) { - /* - * Here we register the result into the context to allow circular dependency, it's done before mapping so if there is a circular dependency, it will be correctly handled - * - * $context = MapperContext::withReference($context, $sourceHash, $result); - */ - $addedDependenciesStatements[] = new Stmt\Expression(new Expr\Assign( - $contextVariable, - new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'withReference', [ - new Arg($contextVariable), - new Arg($hashVariable), - new Arg($result), - ]) - )); - } - - /* - * We increase the depth of the context to allow to check the max depth of the mapping - * - * $context = MapperContext::withIncrementedDepth($context); - */ - $addedDependenciesStatements[] = new Stmt\Expression(new Expr\Assign( - $contextVariable, - new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'withIncrementedDepth', [ - new Arg($contextVariable), - ]) - )); - } - - $duplicatedStatements = []; - $setterStatements = []; - foreach ($propertiesMapping as $propertyMapping) { - /* - * This is the main loop to map the properties from the source to the target, there is 3 main steps in order to generated this code : - * - * * Generate code on how to read the value from the source, which returns statements and an output expression - * * Generate code on how to transform the value, which use the output expression, add some statements and return a new output expression - * * Generate code on how to write this transformed value to the target, which use the output expression and add some statements - * - * As an example this could generate the following code : - * - * * Extract value from a private property : $this->extractCallbacks['propertyName']($source) - * * Transform the value, which is an object in this example, with another mapper : $this->mappers['SOURCE_TO_TARGET_MAPPER']->map(..., $context); - * * Write the value to a private property : $this->hydrateCallbacks['propertyName']($target, ...) - * - * Since it use expression that may not create variable this would produce the following code - * - * $this->hydrateCallbacks['propertyName']($target, $this->mappers['SOURCE_TO_TARGET_MAPPER']->map($this->extractCallbacks['propertyName']($source), $context)); - */ - if ($propertyMapping->shouldIgnoreProperty($mapperGeneratorMetadata->shouldMapPrivateProperties())) { - continue; - } - - $fieldValueVariable = new Expr\Variable($uniqueVariableScope->getUniqueName('fieldValue')); - - if ($propertyMapping->hasCustomTransformer()) { - $output = $this->customTransformerExtractor->extract($propertyMapping->transformer, $fieldValueVariable, $sourceInput); - $propStatements = []; - } else { - /* Create expression to transform the read value into the wanted written value, depending on the transform it may add new statements to get the correct value */ - [$output, $propStatements] = $propertyMapping->transformer->transform($fieldValueVariable, $result, $propertyMapping, $uniqueVariableScope); - } - - $extractCallback = $propertyMapping->readAccessor?->getExtractCallback($mapperGeneratorMetadata->getSource()); - - if (null !== $extractCallback) { - /* - * Add read callback to the constructor of the generated mapper - * - * $this->extractCallbacks['propertyName'] = $extractCallback; - */ - $constructStatements[] = new Stmt\Expression(new Expr\Assign( - new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'extractCallbacks'), new Scalar\String_($propertyMapping->property)), - $extractCallback - )); - } - - if (null === $propertyMapping->writeMutator) { - continue; - } - - if ($propertyMapping->writeMutator->type !== WriteMutator::TYPE_ADDER_AND_REMOVER) { - /** Create expression to write the transformed value to the target only if not add / remove mutator, as it's already called by the transformer in this case */ - $writeExpression = $propertyMapping->writeMutator->getExpression($result, $output, $propertyMapping->transformer instanceof AssignedByReferenceTransformerInterface ? $propertyMapping->transformer->assignByRef() : false); - if (null === $writeExpression) { - continue; - } - - $propStatements[] = new Stmt\Expression($writeExpression); - } - - $hydrateCallback = $propertyMapping->writeMutator->getHydrateCallback($mapperGeneratorMetadata->getTarget()); - - if (null !== $hydrateCallback) { - /* - * Add hydrate callback to the constructor of the generated mapper - * - * $this->hydrateCallback['propertyName'] = $hydrateCallback; - */ - $constructStatements[] = new Stmt\Expression(new Expr\Assign( - new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'hydrateCallbacks'), new Scalar\String_($propertyMapping->property)), - $hydrateCallback - )); - } - - /** We generate a list of conditions that will allow the field to be mapped to the target */ - $conditions = []; - - if ($propertyMapping->checkExists) { - if (\stdClass::class === $mapperGeneratorMetadata->getSource()) { - /* - * In case of source is an \stdClass we ensure that the property exists - * property_exists($source, 'propertyName') - */ - $conditions[] = new Expr\FuncCall(new Name('property_exists'), [ - new Arg($sourceInput), - new Arg(new Scalar\String_($propertyMapping->property)), - ]); - } - - if ('array' === $mapperGeneratorMetadata->getSource()) { - /* - * In case of source is an array we ensure that the key exists - * array_key_exists('propertyName', $source) - */ - $conditions[] = new Expr\FuncCall(new Name('array_key_exists'), [ - new Arg(new Scalar\String_($propertyMapping->property)), - new Arg($sourceInput), - ]); - } - } - - if ($propertyMapping->readAccessor && $mapperGeneratorMetadata->shouldCheckAttributes()) { - /** Create expression on how to read the value from the source */ - $sourcePropertyAccessor = new Expr\Assign($fieldValueVariable, $propertyMapping->readAccessor->getExpression($sourceInput)); - - /* - * In case of supporting attributes checking, we check if the property is allowed to be mapped - * MapperContext::isAllowedAttribute($context, 'propertyName', $source) - */ - $conditions[] = new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'isAllowedAttribute', [ - new Arg($contextVariable), - new Arg(new Scalar\String_($propertyMapping->property)), - new Arg($sourcePropertyAccessor), - ]); - } - - if (null !== $propertyMapping->sourceGroups) { - /* - * When there is groups associated to the source property we check if the context has the same groups - * - * (null !== $context[MapperContext::GROUPS] ?? null && array_intersect($context[MapperContext::GROUPS] ?? [], ['group1', 'group2'])) - */ - $conditions[] = new Expr\BinaryOp\BooleanAnd( - new Expr\BinaryOp\NotIdentical( - new Expr\ConstFetch(new Name('null')), - new Expr\BinaryOp\Coalesce( - new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::GROUPS)), - new Expr\Array_() - ) - ), - new Expr\FuncCall(new Name('array_intersect'), [ - new Arg(new Expr\BinaryOp\Coalesce( - new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::GROUPS)), - new Expr\Array_() - )), - new Arg(new Expr\Array_(array_map(function (string $group) { - return new Expr\ArrayItem(new Scalar\String_($group)); - }, $propertyMapping->sourceGroups))), - ]) - ); - } - - if (null !== $propertyMapping->targetGroups) { - /* - * When there is groups associated to the target property we check if the context has the same groups - * - * (null !== $context[MapperContext::GROUPS] ?? null && array_intersect($context[MapperContext::GROUPS] ?? [], ['group1', 'group2'])) - */ - $conditions[] = new Expr\BinaryOp\BooleanAnd( - new Expr\BinaryOp\NotIdentical( - new Expr\ConstFetch(new Name('null')), - new Expr\BinaryOp\Coalesce( - new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::GROUPS)), - new Expr\Array_() - ) - ), - new Expr\FuncCall(new Name('array_intersect'), [ - new Arg(new Expr\BinaryOp\Coalesce( - new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::GROUPS)), - new Expr\Array_() - )), - new Arg(new Expr\Array_(array_map(function (string $group) { - return new Expr\ArrayItem(new Scalar\String_($group)); - }, $propertyMapping->targetGroups))), - ]) - ); - } - - if (null !== $propertyMapping->maxDepth) { - /* - * When there is a max depth for this property we check if the context has a depth lower or equal to the max depth - * - * ($context[MapperContext::DEPTH] ?? 0) <= $maxDepth - */ - $conditions[] = new Expr\BinaryOp\SmallerOrEqual( - new Expr\BinaryOp\Coalesce( - new Expr\ArrayDimFetch($contextVariable, new Scalar\String_(MapperContext::DEPTH)), - new Expr\ConstFetch(new Name('0')) - ), - new Scalar\LNumber($propertyMapping->maxDepth) - ); - } - - if ($conditions) { - /* - * If there is any conditions generated we encapsulate the mapping into it. - * - * if (condition1 && condition2 && ...) { - * ... // mapping statements - * } - */ - $condition = array_shift($conditions); - - while ($conditions) { - $condition = new Expr\BinaryOp\BooleanAnd($condition, array_shift($conditions)); - } - - $propStatements = [new Stmt\If_($condition, [ - 'stmts' => $propStatements, - ])]; - } - - /* - * Here we dispatch those statements into two categories - * * Statements that need to be executed before the constructor, if the property need to be written in the constructor - * * Statements that need to be executed after the constructor. - */ - $propInConstructor = \in_array($propertyMapping->property, $inConstructor, true); - foreach ($propStatements as $propStatement) { - if ($propInConstructor) { - $duplicatedStatements[] = $propStatement; - } else { - $setterStatements[] = $propStatement; - } - } - } - - if (\count($duplicatedStatements) > 0 && \count($inConstructor)) { - /* - * Generate else statements when the result is already an object, which means it has already been created, so we need to execute the statements that need to be executed before the constructor since the constructor has already been called - * if (null !== $result { - * .. // create object statements - * } else { - * // remap property from the constructor in case object already exists so we do not loose information - * $source->propertyName = $this->extractCallbacks['propertyName']($source); - * ... - * } - */ - $statements[] = new Stmt\Else_(array_merge($addedDependenciesStatements, $duplicatedStatements)); - } else { - foreach ($addedDependenciesStatements as $statement) { - $statements[] = $statement; - } - } - - /* Add the rest of statements to handle the mapping */ - foreach ($setterStatements as $propStatement) { - $statements[] = $propStatement; - } - - /* return $result; */ - $statements[] = new Stmt\Return_($result); - - /* - * Create the map method for this mapper. - * - * public function map($source, array $context = []) { - * ... // statements - * } - */ - $mapMethod = new Stmt\ClassMethod('map', [ - 'flags' => Stmt\Class_::MODIFIER_PUBLIC, - 'params' => [ - new Param(new Expr\Variable($sourceInput->name)), - new Param(new Expr\Variable('context'), new Expr\Array_(), 'array'), - ], - 'byRef' => true, - 'stmts' => $statements, - 'returnType' => \PHP_VERSION_ID >= 80000 ? 'mixed' : null, - ]); - - /* - * Create the constructor for this mapper. - * - * public function __construct() { - * // construct statements - * $this->extractCallbacks['propertyName'] = \Closure::bind(function ($object) { - * return $object->propertyName; - * }; - * ... - * } - */ - $constructMethod = new Stmt\ClassMethod('__construct', [ - 'flags' => Stmt\Class_::MODIFIER_PUBLIC, - 'stmts' => $constructStatements, - ]); - - $classStmts = [$constructMethod, $mapMethod]; - - if (\count($injectMapperStatements) > 0) { - /* - * Create the injectMapper methods for this mapper - * - * This is not done into the constructor in order to avoid circular dependency between mappers - * - * public function injectMappers(AutoMapperRegistryInterface $autoMapperRegistry) { - * // inject mapper statements - * $this->mappers['SOURCE_TO_TARGET_MAPPER'] = $autoMapperRegistry->getMapper($source, $target); - * ... - * } - */ - $classStmts[] = new Stmt\ClassMethod('injectMappers', [ - 'flags' => Stmt\Class_::MODIFIER_PUBLIC, - 'params' => [ - new Param(new Expr\Variable('autoMapperRegistry'), null, new Name\FullyQualified(AutoMapperRegistryInterface::class)), - ], - 'returnType' => 'void', - 'stmts' => $injectMapperStatements, - ]); - } - - /* - * Create the class for this mapper - * - * final class SourceToTargetMapper extends GeneratedMapper { - * ... // class methods - * } - */ - return new Stmt\Class_($mapperGeneratorMetadata->getMapperClassName(), [ - 'flags' => Stmt\Class_::MODIFIER_FINAL, - 'extends' => new Name\FullyQualified(GeneratedMapper::class), - 'stmts' => $classStmts, - ]); - } - - private function getCreateObjectStatements(MapperGeneratorMetadataInterface $mapperMetadata, Expr\Variable $result, Expr\Variable $contextVariable, Expr\Variable $sourceInput, UniqueVariableScope $uniqueVariableScope): array - { - $target = $mapperMetadata->getTarget(); - $source = $mapperMetadata->getSource(); - - if ('array' === $target) { - /* - * If the target is an array, we just create an empty array. - * $result = []; - */ - return [[new Stmt\Expression(new Expr\Assign($result, new Expr\Array_()))], [], [], []]; - } - - if (\stdClass::class === $target && \stdClass::class === $source) { - /* - * If the target and source is a stdClass, we just clone the object using serialization - * $result = unserialize(serialize($source)); - */ - return [[new Stmt\Expression(new Expr\Assign($result, new Expr\FuncCall(new Name('unserialize'), [new Arg(new Expr\FuncCall(new Name('serialize'), [new Arg($sourceInput)]))])))], [], [], []]; - } - - if (\stdClass::class === $target) { - /* - * If the target is a stdClass, we create a new stdClass - * $result = \new stdClass(); - */ - return [[new Stmt\Expression(new Expr\Assign($result, new Expr\New_(new Name(\stdClass::class))))], [], [], []]; - } - - $reflectionClass = new \ReflectionClass($target); - $targetConstructor = $reflectionClass->getConstructor(); - $createObjectStatements = []; - $inConstructor = []; - $constructStatements = []; - $injectMapperStatements = []; - $classDiscriminatorMapping = 'array' !== $target && null !== $this->classDiscriminator ? $this->classDiscriminator->getMappingForClass($target) : null; - - if ( - null !== $classDiscriminatorMapping - && null !== ($propertyMapping = $mapperMetadata->getPropertyMapping($classDiscriminatorMapping->getTypeProperty())) - && !$propertyMapping->hasCustomTransformer() - ) { - /* - * Generate the code that allows to put the type into the output variable, - * so we are able to determine which mapper to use - */ - [$output, $createObjectStatements] = $propertyMapping->transformer->transform($propertyMapping->readAccessor->getExpression($sourceInput), $result, $propertyMapping, $uniqueVariableScope); - - foreach ($classDiscriminatorMapping->getTypesMapping() as $typeValue => $typeTarget) { - $mapperName = 'Discriminator_Mapper_' . $source . '_' . $typeTarget; - - /* - * We inject dependencies for all the discriminator variant - * - * $this->mappers['Discriminator_Mapper_VariantA'] = $autoMapperRegistry->getMapper($source, VariantA::class); - * $this->mappers['Discriminator_Mapper_VariantB'] = $autoMapperRegistry->getMapper($source, VariantB::class); - * ... - */ - $injectMapperStatements[] = new Stmt\Expression(new Expr\Assign( - new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), new Scalar\String_($mapperName)), - new Expr\MethodCall(new Expr\Variable('autoMapperRegistry'), 'getMapper', [ - new Arg(new Scalar\String_($source)), - new Arg(new Scalar\String_($typeTarget)), - ]) - )); - - /* - * We return the object created with the correct mapper depending on the variant, this will skip the next mapping phase in this situation - * - * if ('VariantA' === $output) { - * return $this->mappers['Discriminator_Mapper_VariantA']->map($source, $context); - * } - */ - $createObjectStatements[] = new Stmt\If_(new Expr\BinaryOp\Identical( - new Scalar\String_($typeValue), - $output - ), [ - 'stmts' => [ - new Stmt\Return_(new Expr\MethodCall(new Expr\ArrayDimFetch( - new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), - new Scalar\String_($mapperName) - ), 'map', [ - new Arg($sourceInput), - new Expr\Variable('context'), - ])), - ], - ]); - } - } - - $propertiesMapping = $mapperMetadata->getPropertiesMapping(); - - if (null !== $targetConstructor && $mapperMetadata->hasConstructor()) { - $constructArguments = []; - - foreach ($propertiesMapping as $propertyMapping) { - /* - * This is the main loop to map the properties from the source to the target in the constructor, there is 2 main steps in order to generated this code : - * - * * Generate code on how to read the value from the source, which returns statements and an output expression - * * Generate code on how to transform the value, which use the output expression, add some statements and return a new output expression - * - * As an example this could generate the following code : - * - * * Extract value from a private property : $this->extractCallbacks['propertyName']($source) - * * Transform the value, which is an object in this example, with another mapper : $this->mappers['SOURCE_TO_TARGET_MAPPER']->map(..., $context); - * - * The output expression of the transform will then be used as argument for the object constructor - * - * $constructArg1 = $this->mappers['SOURCE_TO_TARGET_MAPPER']->map($this->extractCallbacks['propertyName']($source), $context); - * $result = new Foo($constructArg1); - */ - if (null === $propertyMapping->writeMutatorConstructor || null === ($parameter = $propertyMapping->writeMutatorConstructor->parameter)) { - continue; - } - - $constructVar = new Expr\Variable($uniqueVariableScope->getUniqueName('constructArg')); - - if ($propertyMapping->hasCustomTransformer()) { - $propStatements = []; - /* - * let's extract custom transformer's transform() method in a closure, - * and add it as a constructor argument - */ - $output = $this->customTransformerExtractor->extract( - $propertyMapping->transformer, - $propertyMapping->readAccessor?->getExpression($sourceInput), - $sourceInput - ); - } else { - /* Get extract and transform statements for this property */ - [$output, $propStatements] = $propertyMapping->transformer->transform( - $propertyMapping->readAccessor->getExpression($sourceInput), - $constructVar, - $propertyMapping, - $uniqueVariableScope - ); - } - - $constructArguments[$parameter->getPosition()] = new Arg($constructVar); - - /* - * Check if there is a constructor argument in the context, otherwise we use the transformed value - * - * if (MapperContext::hasConstructorArgument($context, $target, 'propertyName')) { - * $constructArg1 = MapperContext::getConstructorArgument($context, $target, 'propertyName'); - * } else { - * $constructArg1 = $source->propertyName; - * } - */ - $propStatements[] = new Stmt\Expression(new Expr\Assign($constructVar, $output)); - $createObjectStatements[] = new Stmt\If_(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'hasConstructorArgument', [ - new Arg($contextVariable), - new Arg(new Scalar\String_($target)), - new Arg(new Scalar\String_($propertyMapping->property)), - ]), [ - 'stmts' => [ - new Stmt\Expression(new Expr\Assign($constructVar, new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'getConstructorArgument', [ - new Arg($contextVariable), - new Arg(new Scalar\String_($target)), - new Arg(new Scalar\String_($propertyMapping->property)), - ]))), - ], - 'else' => new Stmt\Else_($propStatements), - ]); - - $inConstructor[] = $propertyMapping->property; - } - - /* We loop to get constructor arguments that were not present in the source */ - foreach ($targetConstructor->getParameters() as $constructorParameter) { - if (!\array_key_exists($constructorParameter->getPosition(), $constructArguments) && $constructorParameter->isDefaultValueAvailable()) { - $constructVar = new Expr\Variable($uniqueVariableScope->getUniqueName('constructArg')); - - /* - * Check if there is a constructor argument in the context, otherwise we use the default value - * - * if (MapperContext::hasConstructorArgument($context, $target, 'propertyName')) { - * $constructArg2 = MapperContext::getConstructorArgument($context, $target, 'propertyName'); - * } else { - * $constructArg2 = 'default value'; - * } - */ - $createObjectStatements[] = new Stmt\If_(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'hasConstructorArgument', [ - new Arg($contextVariable), - new Arg(new Scalar\String_($target)), - new Arg(new Scalar\String_($constructorParameter->getName())), - ]), [ - 'stmts' => [ - new Stmt\Expression(new Expr\Assign($constructVar, new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'getConstructorArgument', [ - new Arg($contextVariable), - new Arg(new Scalar\String_($target)), - new Arg(new Scalar\String_($constructorParameter->getName())), - ]))), - ], - 'else' => new Stmt\Else_([ - new Stmt\Expression(new Expr\Assign($constructVar, $this->getValueAsExpr($constructorParameter->getDefaultValue()))), - ]), - ]); - - $constructArguments[$constructorParameter->getPosition()] = new Arg($constructVar); - } - } - - ksort($constructArguments); - - /* - * Create object with the constructor arguments - * - * $result = new Foo($constructArg1, $constructArg2, ...); - */ - $createObjectStatements[] = new Stmt\Expression(new Expr\Assign($result, new Expr\New_(new Name\FullyQualified($target), $constructArguments))); - } elseif (null !== $targetConstructor && $mapperMetadata->isTargetCloneable()) { - /* - * When the target does not have a constructor but is cloneable, we clone a cached version of the target created with reflection to improve performance - * - * // constructor of mapper - * $this->cachedTarget = (new \ReflectionClass(Foo:class))->newInstanceWithoutConstructor(); - * - * // map method - * $result = clone $this->cachedTarget; - */ - $constructStatements[] = new Stmt\Expression(new Expr\Assign( - new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'), - new Expr\MethodCall(new Expr\New_(new Name\FullyQualified(\ReflectionClass::class), [ - new Arg(new Scalar\String_($target)), - ]), 'newInstanceWithoutConstructor') - )); - $createObjectStatements[] = new Stmt\Expression(new Expr\Assign($result, new Expr\Clone_(new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget')))); - } elseif (null !== $targetConstructor) { - /* - * When the target does not have a constructor and is not cloneable, we cache the reflection class to improve performance - * - * // constructor of mapper - * $this->cachedTarget = (new \ReflectionClass(Foo:class)); - * - * // map method - * $result = $this->cachedTarget->newInstanceWithoutConstructor(); - */ - $constructStatements[] = new Stmt\Expression(new Expr\Assign( - new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'), - new Expr\New_(new Name\FullyQualified(\ReflectionClass::class), [ - new Arg(new Scalar\String_($target)), - ]) - )); - $createObjectStatements[] = new Stmt\Expression(new Expr\Assign($result, new Expr\MethodCall( - new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'), - 'newInstanceWithoutConstructor' - ))); - } else { - /* - * Create object with constructor (which have no arguments) - * - * $result = new Foo(); - */ - $createObjectStatements[] = new Stmt\Expression(new Expr\Assign($result, new Expr\New_(new Name\FullyQualified($target)))); - } - - return [$createObjectStatements, $inConstructor, $constructStatements, $injectMapperStatements]; - } - - private function getValueAsExpr($value) - { - $expr = $this->parser->parse('expr; - } - - return $expr; - } -} diff --git a/src/Generator/InjectMapperMethodStatementsGenerator.php b/src/Generator/InjectMapperMethodStatementsGenerator.php new file mode 100644 index 00000000..c9cdfbf4 --- /dev/null +++ b/src/Generator/InjectMapperMethodStatementsGenerator.php @@ -0,0 +1,68 @@ +mappers['SOURCE_TO_TARGET_MAPPER'] = $autoMapperRegistry->getMapper($source, $target); + * ... + * } + * ``` + * + * @internal + */ +final readonly class InjectMapperMethodStatementsGenerator +{ + public function __construct(private DiscriminatorStatementsGenerator $discriminatorStatementsGenerator) + { + } + + /** + * @return list + */ + public function getStatements(Expr\Variable $automapperRegistryVariable, MapperGeneratorMetadataInterface $mapperMetadata): array + { + $injectMapperStatements = []; + + foreach ($mapperMetadata->getAllDependencies() as $dependency) { + /* + * If the transformer has dependencies, we inject the mappers for the dependencies + * This allows to inject mappers when creating the service instead of resolving them at runtime which is faster + * + * $this->mappers[$dependency->name] = $autoMapperRegistry->getMapper($dependency->source, $dependency->target); + */ + $injectMapperStatements[] = new Stmt\Expression( + new Expr\Assign( + new Expr\ArrayDimFetch( + new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), + new Scalar\String_($dependency->name) + ), + new Expr\MethodCall($automapperRegistryVariable, 'getMapper', [ + new Arg(new Scalar\String_($dependency->source)), + new Arg(new Scalar\String_($dependency->target)), + ]) + ) + ); + } + + return [ + ...$injectMapperStatements, + ...$this->discriminatorStatementsGenerator->injectMapperStatements($mapperMetadata), + ]; + } +} diff --git a/src/Generator/MapMethodStatementsGenerator.php b/src/Generator/MapMethodStatementsGenerator.php new file mode 100644 index 00000000..ff99628c --- /dev/null +++ b/src/Generator/MapMethodStatementsGenerator.php @@ -0,0 +1,312 @@ +createObjectStatementsGenerator = new CreateTargetStatementsGenerator( + $discriminatorStatementsGenerator, + $cachedReflectionStatementsGenerator, + $customTransformerExtractor + ); + $this->propertyStatementsGenerator = new PropertyStatementsGenerator($customTransformerExtractor); + } + + /** + * @return list + */ + public function getStatements(MapperGeneratorMetadataInterface $mapperMetadata): array + { + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + $statements = [$this->ifSourceIsNullReturnNull($mapperMetadata)]; + $statements = [...$statements, ...$this->handleCircularReference($mapperMetadata)]; + $statements = [...$statements, ...$this->initializeTargetToPopulate($mapperMetadata)]; + $statements[] = $this->createObjectStatementsGenerator->generate($mapperMetadata, $variableRegistry); + + $addedDependenciesStatements = $this->handleDependencies($mapperMetadata); + + $duplicatedStatements = []; + $setterStatements = []; + foreach ($mapperMetadata->getPropertiesMapping() as $propertyMapping) { + /** + * This is the main loop to map the properties from the source to the target, there is 3 main steps in order to generate this code :. + * + * * Generate code on how to read the value from the source, which returns statements and an output expression + * * Generate code on how to transform the value, which use the output expression, add some statements and return a new output expression + * * Generate code on how to write this transformed value to the target, which use the output expression and add some statements + * + * As an example this could generate the following code : + * + * * Extract value from a private property : $this->extractCallbacks['propertyName']($source) + * * Transform the value, which is an object in this example, with another mapper : $this->mappers['SOURCE_TO_TARGET_MAPPER']->map(..., $context); + * * Write the value to a private property : $this->hydrateCallbacks['propertyName']($target, ...) + * + * Since it use expression that may not create variable this would produce the following code + * + * ```php + * $this->hydrateCallbacks['propertyName']($target, $this->mappers['SOURCE_TO_TARGET_MAPPER']->map($this->extractCallbacks['propertyName']($source), $context)); + * ``` + */ + $propStatements = $this->propertyStatementsGenerator->generate($propertyMapping); + + /* + * Dispatch those statements into two categories: + * - Statements that need to be executed before the constructor, if the property needs to be written in the constructor + * - Statements that need to be executed after the constructor. + */ + if (\in_array($propertyMapping->property, $mapperMetadata->getPropertiesInConstructor(), true)) { + $duplicatedStatements = [...$duplicatedStatements, ...$propStatements]; + } else { + $setterStatements = [...$setterStatements, ...$propStatements]; + } + } + + if (\count($duplicatedStatements) > 0 && \count($mapperMetadata->getPropertiesInConstructor())) { + /* + * Generate else statements when the result is already an object, which means it has already been created, + * so we need to execute the statements that need to be executed before the constructor since the constructor has already been called + * + * ```php + * if (null !== $result) { + * .. // create object statements + * } else { + * // remap property from the constructor in case object already exists so we do not loose information + * $source->propertyName = $this->extractCallbacks['propertyName']($source); + * ... + * } + * ``` + */ + $statements[] = new Stmt\Else_(array_merge($addedDependenciesStatements, $duplicatedStatements)); + } else { + $statements = [...$statements, ...$addedDependenciesStatements]; + } + + return [ + ...$statements, + ...$setterStatements, + new Stmt\Return_($variableRegistry->getResult()), + ]; + } + + /** + * If the source is null, if so, return null. + * + * ```php + * if (null === $source) { + * return $source; + * } + * ``` + */ + private function ifSourceIsNullReturnNull(MapperGeneratorMetadataInterface $mapperMetadata): Stmt + { + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return new Stmt\If_( + new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), $variableRegistry->getSourceInput()), + [ + 'stmts' => [new Stmt\Return_($variableRegistry->getSourceInput())], + ] + ); + } + + /** + * When there can be circular dependency in the mapping, + * the following statements try to use the reference for the source if it's available. + * + * ```php + * $sourceHash = spl_object_hash($source) . $target; + * if (MapperContext::shouldHandleCircularReference($context, $sourceHash, $source)) { + * return MapperContext::handleCircularReference($context, $sourceHash, $source, $this->circularReferenceLimit, $this->circularReferenceHandler); + * } + * ``` + * + * @return list + */ + private function handleCircularReference(MapperGeneratorMetadataInterface $mapperMetadata): array + { + if (!$mapperMetadata->canHaveCircularReference()) { + return []; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return [ + new Stmt\Expression( + new Expr\Assign( + $variableRegistry->getHash(), + new Expr\BinaryOp\Concat(new Expr\FuncCall(new Name('spl_object_hash'), [ + new Arg($variableRegistry->getSourceInput()), + ]), + new Scalar\String_($mapperMetadata->getTarget()) + ) + ) + ), + new Stmt\If_( + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'shouldHandleCircularReference', [ + new Arg($variableRegistry->getContext()), + new Arg($variableRegistry->getHash()), + new Arg(new Expr\PropertyFetch(new Expr\Variable('this'), 'circularReferenceLimit')), + ]), [ + 'stmts' => [ + new Stmt\Return_( + new Expr\StaticCall( + new Name\FullyQualified(MapperContext::class), + 'handleCircularReference', + [ + new Arg($variableRegistry->getContext()), + new Arg($variableRegistry->getHash()), + new Arg($variableRegistry->getSourceInput()), + new Arg( + new Expr\PropertyFetch(new Expr\Variable('this'), 'circularReferenceLimit') + ), + new Arg( + new Expr\PropertyFetch(new Expr\Variable('this'), 'circularReferenceHandler') + ), + ] + ) + ), + ], + ] + ), + ]; + } + + /** + * @return list + */ + private function initializeTargetToPopulate(MapperGeneratorMetadataInterface $mapperMetadata): array + { + $variableRegistry = $mapperMetadata->getVariableRegistry(); + $targetToPopulate = new Expr\ArrayDimFetch($variableRegistry->getContext(), new Scalar\String_(MapperContext::TARGET_TO_POPULATE)); + + $statements = []; + + /* + * Get result from context if available, otherwise set it to null + * + * ```php + * $result = $context[MapperContext::TARGET_TO_POPULATE] ?? null; + * ``` + */ + $statements[] = new Stmt\Expression( + new Expr\Assign( + $variableRegistry->getResult(), + new Expr\BinaryOp\Coalesce($targetToPopulate, new Expr\ConstFetch(new Name('null'))) + ) + ); + + if (!$this->allowReadOnlyTargetToPopulate && $mapperMetadata->isTargetReadOnlyClass()) { + /* + * If the target is a read-only class, we throw an exception if the target is not null + * + * ```php + * if ($contextVariable[MapperContext::ALLOW_READONLY_TARGET_TO_POPULATE] ?? false && is_object($targetToPopulate)) { + * throw new ReadOnlyTargetException(); + * } + * ``` + */ + $statements[] = new Stmt\If_( + new Expr\BinaryOp\BooleanAnd( + new Expr\BooleanNot( + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch( + $variableRegistry->getContext(), + new Scalar\String_(MapperContext::ALLOW_READONLY_TARGET_TO_POPULATE) + ), new Expr\ConstFetch(new Name('false')) + ) + ), + new Expr\FuncCall( + new Name('is_object'), + [new Arg(new Expr\BinaryOp\Coalesce($targetToPopulate, new Expr\ConstFetch(new Name('null'))))] + ) + ), [ + 'stmts' => [ + new Stmt\Expression( + new Expr\Throw_(new Expr\New_(new Name(ReadOnlyTargetException::class))) + ), + ], + ] + ); + } + + return $statements; + } + + /** + * @return list + */ + private function handleDependencies(MapperGeneratorMetadataInterface $mapperMetadata): array + { + if (!$mapperMetadata->getAllDependencies()) { + return []; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + $addedDependenciesStatements = []; + if ($mapperMetadata->canHaveCircularReference()) { + /* + * Here we register the result into the context to allow circular dependency, it's done before mapping so if there is a circular dependency, it will be correctly handled + * + * ```php + * $context = MapperContext::withReference($context, $sourceHash, $result); + * ``` + */ + $addedDependenciesStatements[] = new Stmt\Expression( + new Expr\Assign( + $variableRegistry->getContext(), + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'withReference', [ + new Arg($variableRegistry->getContext()), + new Arg($variableRegistry->getHash()), + new Arg($variableRegistry->getResult()), + ]) + ) + ); + } + + /* + * We increase the depth of the context to allow to check the max depth of the mapping + * + * ```php + * $context = MapperContext::withIncrementedDepth($context); + * ``` + */ + $addedDependenciesStatements[] = new Stmt\Expression( + new Expr\Assign( + $variableRegistry->getContext(), + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'withIncrementedDepth', [ + new Arg($variableRegistry->getContext()), + ]) + ) + ); + + return $addedDependenciesStatements; + } +} diff --git a/src/Generator/MapperConstructorGenerator.php b/src/Generator/MapperConstructorGenerator.php new file mode 100644 index 00000000..795f6f72 --- /dev/null +++ b/src/Generator/MapperConstructorGenerator.php @@ -0,0 +1,87 @@ + + */ + public function getStatements(MapperGeneratorMetadataInterface $mapperMetadata): array + { + $constructStatements = []; + + foreach ($mapperMetadata->getPropertiesMapping() as $propertyMapping) { + $constructStatements[] = $this->extractCallbackForProperty($propertyMapping); + $constructStatements[] = $this->hydrateCallbackForProperty($propertyMapping); + } + + $constructStatements[] = $this->cachedReflectionStatementsGenerator->mapperConstructorStatement($mapperMetadata); + + return array_values(array_filter($constructStatements)); + } + + /** + * Add read callback to the constructor of the generated mapper. + * + * ```php + * $this->extractCallbacks['propertyName'] = $extractCallback; + * ``` + */ + private function extractCallbackForProperty(PropertyMapping $propertyMapping): Stmt\Expression|null + { + $mapperMetadata = $propertyMapping->mapperMetadata; + + $extractCallback = $propertyMapping->readAccessor?->getExtractCallback($mapperMetadata->getSource()); + + if (!$extractCallback) { + return null; + } + + return new Stmt\Expression( + new Expr\Assign( + new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'extractCallbacks'), new Scalar\String_($propertyMapping->property)), + $extractCallback + )); + } + + /** + * Add hydrate callback to the constructor of the generated mapper. + * + * ```php + * $this->hydrateCallback['propertyName'] = $hydrateCallback; + * ``` + */ + private function hydrateCallbackForProperty(PropertyMapping $propertyMapping): Stmt\Expression|null + { + $mapperMetadata = $propertyMapping->mapperMetadata; + + $hydrateCallback = $propertyMapping->writeMutator?->getHydrateCallback($mapperMetadata->getTarget()); + + if (!$hydrateCallback) { + return null; + } + + return new Stmt\Expression(new Expr\Assign( + new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'hydrateCallbacks'), new Scalar\String_($propertyMapping->property)), + $hydrateCallback + )); + } +} diff --git a/src/Generator/MapperGenerator.php b/src/Generator/MapperGenerator.php new file mode 100644 index 00000000..c05a693b --- /dev/null +++ b/src/Generator/MapperGenerator.php @@ -0,0 +1,136 @@ + + * + * @internal + */ +final readonly class MapperGenerator +{ + private MapperConstructorGenerator $mapperConstructorGenerator; + private InjectMapperMethodStatementsGenerator $injectMapperMethodStatementsGenerator; + private MapMethodStatementsGenerator $mapMethodStatementsGenerator; + + public function __construct( + CustomTransformerExtractor $customTransformerExtractor, + ClassDiscriminatorResolver $classDiscriminatorResolver, + bool $allowReadOnlyTargetToPopulate = false, + ) { + $this->mapperConstructorGenerator = new MapperConstructorGenerator( + $cachedReflectionStatementsGenerator = new CachedReflectionStatementsGenerator() + ); + + $this->mapMethodStatementsGenerator = new MapMethodStatementsGenerator( + $discriminatorStatementsGenerator = new DiscriminatorStatementsGenerator($classDiscriminatorResolver), + $cachedReflectionStatementsGenerator, + $customTransformerExtractor, + $allowReadOnlyTargetToPopulate, + ); + + $this->injectMapperMethodStatementsGenerator = new InjectMapperMethodStatementsGenerator( + $discriminatorStatementsGenerator + ); + } + + /** + * Generate Class AST given metadata for a mapper. + * + * @throws CompileException + */ + public function generate(MapperGeneratorMetadataInterface $mapperMetadata): Stmt\Class_ + { + return (new Builder\Class_($mapperMetadata->getMapperClassName())) + ->makeFinal() + ->extend(GeneratedMapper::class) + ->addStmt($this->constructorMethod($mapperMetadata)) + ->addStmt($this->mapMethod($mapperMetadata)) + ->addStmt($this->injectMappersMethod($mapperMetadata)) + ->getNode(); + } + + /** + * Create the constructor for this mapper. + * + * ```php + * public function __construct() { + * // construct statements + * $this->extractCallbacks['propertyName'] = \Closure::bind(function ($object) { + * return $object->propertyName; + * }; + * ... + * } + * ``` + */ + private function constructorMethod(MapperGeneratorMetadataInterface $mapperMetadata): Stmt\ClassMethod + { + return (new Builder\Method('__construct')) + ->makePublic() + ->addStmts($this->mapperConstructorGenerator->getStatements($mapperMetadata)) + ->getNode(); + } + + /** + * Create the map method for this mapper. + * + * ```php + * public function map($source, array $context = []) { + * ... // statements + * } + * ``` + */ + private function mapMethod(MapperGeneratorMetadataInterface $mapperMetadata): Stmt\ClassMethod + { + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + return (new Builder\Method('map')) + ->makePublic() + ->setReturnType('mixed') + ->makeReturnByRef() + ->addParam(new Param($variableRegistry->getSourceInput())) + ->addParam(new Param($variableRegistry->getContext(), default: new Expr\Array_(), type: 'array')) + ->addStmts($this->mapMethodStatementsGenerator->getStatements($mapperMetadata)) + ->getNode(); + } + + /** + * Create the injectMapper methods for this mapper. + * + * This is not done into the constructor in order to avoid circular dependency between mappers + * + * ```php + * public function injectMappers(AutoMapperRegistryInterface $autoMapperRegistry) { + * // inject mapper statements + * $this->mappers['SOURCE_TO_TARGET_MAPPER'] = $autoMapperRegistry->getMapper($source, $target); + * ... + * } + * ``` + */ + private function injectMappersMethod(MapperGeneratorMetadataInterface $mapperMetadata): Stmt\ClassMethod + { + return (new Builder\Method('injectMappers')) + ->makePublic() + ->setReturnType('void') + ->addParam(new Param(var: $param = new Expr\Variable('autoMapperRegistry'), type: AutoMapperRegistryInterface::class)) + ->addStmts($this->injectMapperMethodStatementsGenerator->getStatements($param, $mapperMetadata)) + ->getNode(); + } +} diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php new file mode 100644 index 00000000..68371ba0 --- /dev/null +++ b/src/Generator/PropertyConditionsGenerator.php @@ -0,0 +1,225 @@ +propertyExistsForStdClass($propertyMapping); + $conditions[] = $this->propertyExistsForArray($propertyMapping); + $conditions[] = $this->isAllowedAttribute($propertyMapping); + $conditions[] = $this->sourceGroupsCheck($propertyMapping); + $conditions[] = $this->targetGroupsCheck($propertyMapping); + $conditions[] = $this->maxDepthCheck($propertyMapping); + + $conditions = array_values(array_filter($conditions)); + + if (!$conditions) { + return null; + } + + /** + * If there is any conditions generated we encapsulate the mapping into it. + * + * ```php + * if (condition1 && condition2 && ...) { + * ... // mapping statements + * } + * ``` + */ + $condition = array_shift($conditions); + + while ($conditions) { + $condition = new Expr\BinaryOp\BooleanAnd($condition, array_shift($conditions)); + } + + return $condition; + } + + /** + * In case of source is an \stdClass we ensure that the property exists. + * + * ```php + * property_exists($source, 'propertyName') + * ``` + */ + private function propertyExistsForStdClass(PropertyMapping $propertyMapping): Expr|null + { + $variableRegistry = $propertyMapping->mapperMetadata->getVariableRegistry(); + + if (!$propertyMapping->checkExists || \stdClass::class !== $propertyMapping->mapperMetadata->getSource()) { + return null; + } + + return new Expr\FuncCall(new Name('property_exists'), [ + new Arg($variableRegistry->getSourceInput()), + new Arg(new Scalar\String_($propertyMapping->property)), + ]); + } + + /** + * In case of source is an array we ensure that the key exists. + * + * ```php + * array_key_exists('propertyName', $source). + * ``` + */ + private function propertyExistsForArray(PropertyMapping $propertyMapping): Expr|null + { + if (!$propertyMapping->checkExists || 'array' !== $propertyMapping->mapperMetadata->getSource()) { + return null; + } + + $variableRegistry = $propertyMapping->mapperMetadata->getVariableRegistry(); + + return new Expr\FuncCall(new Name('array_key_exists'), [ + new Arg(new Scalar\String_($propertyMapping->property)), + new Arg($variableRegistry->getSourceInput()), + ]); + } + + /** + * In case of supporting attributes checking, we check if the property is allowed to be mapped. + * + * ```php + * MapperContext::isAllowedAttribute($context, 'propertyName', $source). + * ``` + */ + private function isAllowedAttribute(PropertyMapping $propertyMapping): Expr|null + { + $mapperMetadata = $propertyMapping->mapperMetadata; + + if (!$propertyMapping->readAccessor || !$mapperMetadata->shouldCheckAttributes()) { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + /** Create expression on how to read the value from the source */ + $sourcePropertyAccessor = new Expr\Assign( + $variableRegistry->getFieldValueVariable($propertyMapping), + $propertyMapping->readAccessor->getExpression($variableRegistry->getSourceInput()) + ); + + return new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'isAllowedAttribute', [ + new Arg($variableRegistry->getContext()), + new Arg(new Scalar\String_($propertyMapping->property)), + new Arg($sourcePropertyAccessor), + ]); + } + + /** + * When there are groups associated to the source property we check if the context has the same groups. + * + * ```php + * (null !== $context[MapperContext::GROUPS] ?? null && array_intersect($context[MapperContext::GROUPS] ?? [], ['group1', 'group2'])) + * ``` + */ + private function sourceGroupsCheck(PropertyMapping $propertyMapping): Expr|null + { + if (!$propertyMapping->sourceGroups) { + return null; + } + + $variableRegistry = $propertyMapping->mapperMetadata->getVariableRegistry(); + + return new Expr\BinaryOp\BooleanAnd( + new Expr\BinaryOp\NotIdentical( + new Expr\ConstFetch(new Name('null')), + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch($variableRegistry->getContext(), new Scalar\String_(MapperContext::GROUPS)), + new Expr\Array_() + ) + ), + new Expr\FuncCall(new Name('array_intersect'), [ + new Arg( + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch($variableRegistry->getContext(), new Scalar\String_(MapperContext::GROUPS)), + new Expr\Array_() + ) + ), + new Arg(new Expr\Array_(array_map(function (string $group) { + return new Expr\ArrayItem(new Scalar\String_($group)); + }, $propertyMapping->sourceGroups))), + ]) + ); + } + + /** + * When there is groups associated to the target property we check if the context has the same groups. + * + * ```php + * (null !== $context[MapperContext::GROUPS] ?? null && array_intersect($context[MapperContext::GROUPS] ?? [], ['group1', 'group2'])) + * ``` + */ + private function targetGroupsCheck(PropertyMapping $propertyMapping): Expr|null + { + if (!$propertyMapping->targetGroups) { + return null; + } + + $variableRegistry = $propertyMapping->mapperMetadata->getVariableRegistry(); + + return new Expr\BinaryOp\BooleanAnd( + new Expr\BinaryOp\NotIdentical( + new Expr\ConstFetch(new Name('null')), + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch($variableRegistry->getContext(), new Scalar\String_(MapperContext::GROUPS)), + new Expr\Array_() + ) + ), + new Expr\FuncCall(new Name('array_intersect'), [ + new Arg( + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch($variableRegistry->getContext(), new Scalar\String_(MapperContext::GROUPS)), + new Expr\Array_() + ) + ), + new Arg(new Expr\Array_(array_map(function (string $group) { + return new Expr\ArrayItem(new Scalar\String_($group)); + }, $propertyMapping->targetGroups))), + ]) + ); + } + + /** + * When there is a max depth for this property we check if the context has a depth lower or equal to the max depth. + * + * ```php + * ($context[MapperContext::DEPTH] ?? 0) <= $maxDepth + * ``` + */ + private function maxDepthCheck(PropertyMapping $propertyMapping): Expr|null + { + if (!$propertyMapping->maxDepth) { + return null; + } + + $variableRegistry = $propertyMapping->mapperMetadata->getVariableRegistry(); + + return new Expr\BinaryOp\SmallerOrEqual( + new Expr\BinaryOp\Coalesce( + new Expr\ArrayDimFetch($variableRegistry->getContext(), new Scalar\String_(MapperContext::DEPTH)), + new Expr\ConstFetch(new Name('0')) + ), + new Scalar\LNumber($propertyMapping->maxDepth) + ); + } +} diff --git a/src/Generator/PropertyStatementsGenerator.php b/src/Generator/PropertyStatementsGenerator.php new file mode 100644 index 00000000..822f8a57 --- /dev/null +++ b/src/Generator/PropertyStatementsGenerator.php @@ -0,0 +1,82 @@ +propertyConditionsGenerator = new PropertyConditionsGenerator(); + } + + /** + * @return list + */ + public function generate(PropertyMapping $propertyMapping): array + { + $mapperMetadata = $propertyMapping->mapperMetadata; + + if ($propertyMapping->shouldIgnoreProperty($mapperMetadata->shouldMapPrivateProperties())) { + return []; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + $fieldValueVariable = $variableRegistry->getFieldValueVariable($propertyMapping); + + if ($propertyMapping->hasCustomTransformer()) { + $output = $this->customTransformerExtractor->extract($propertyMapping->transformer, $fieldValueVariable, $variableRegistry->getSourceInput()); + $propStatements = []; + } else { + /* Create expression to transform the read value into the wanted written value, depending on the transform it may add new statements to get the correct value */ + [$output, $propStatements] = $propertyMapping->transformer->transform( + $fieldValueVariable, + $variableRegistry->getResult(), + $propertyMapping, + $variableRegistry->getUniqueVariableScope() + ); + } + + if ($propertyMapping->writeMutator && $propertyMapping->writeMutator->type !== WriteMutator::TYPE_ADDER_AND_REMOVER) { + /** Create expression to write the transformed value to the target only if not add / remove mutator, as it's already called by the transformer in this case */ + $writeExpression = $propertyMapping->writeMutator->getExpression( + $variableRegistry->getResult(), + $output, + $propertyMapping->transformer instanceof AssignedByReferenceTransformerInterface + ? $propertyMapping->transformer->assignByRef() + : false + ); + if (null === $writeExpression) { + return []; + } + + $propStatements[] = new Stmt\Expression($writeExpression); + } + + $condition = $this->propertyConditionsGenerator->generate($propertyMapping); + + if ($condition) { + $propStatements = [ + new Stmt\If_($condition, [ + 'stmts' => $propStatements, + ]), + ]; + } + + return $propStatements; + } +} diff --git a/src/Generator/Shared/CachedReflectionStatementsGenerator.php b/src/Generator/Shared/CachedReflectionStatementsGenerator.php new file mode 100644 index 00000000..59b4ab50 --- /dev/null +++ b/src/Generator/Shared/CachedReflectionStatementsGenerator.php @@ -0,0 +1,100 @@ +cachedTarget = (new \ReflectionClass(Foo:class))->newInstanceWithoutConstructor(); + * + * // map method + * $result = clone $this->cachedTarget; + * ``` + * + * --- + * + * When the target does not have a constructor and is not cloneable, we cache the reflection class to improve performance. + * + * ```php + * // constructor of mapper + * $this->cachedTarget = (new \ReflectionClass(Foo:class)); + * + * // map method + * $result = $this->cachedTarget->newInstanceWithoutConstructor(); + * ``` + * + * @internal + */ +final readonly class CachedReflectionStatementsGenerator +{ + public function createTargetStatement(MapperGeneratorMetadataInterface $mapperMetadata): Stmt|null + { + if (!$this->supports($mapperMetadata)) { + return null; + } + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + if ($mapperMetadata->isTargetCloneable()) { + return new Stmt\Expression( + new Expr\Assign($variableRegistry->getResult(), new Expr\Clone_(new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'))) + ); + } + + return new Stmt\Expression(new Expr\Assign($variableRegistry->getResult(), new Expr\MethodCall( + new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'), + 'newInstanceWithoutConstructor' + ))); + } + + public function mapperConstructorStatement(MapperGeneratorMetadataInterface $mapperMetadata): Stmt\Expression|null + { + if (!$this->supports($mapperMetadata)) { + return null; + } + + if ($mapperMetadata->isTargetCloneable()) { + return new Stmt\Expression(new Expr\Assign( + new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'), + new Expr\MethodCall(new Expr\New_(new Name\FullyQualified(\ReflectionClass::class), [ + new Arg(new Scalar\String_($mapperMetadata->getTarget())), + ]), 'newInstanceWithoutConstructor') + )); + } + + return new Stmt\Expression(new Expr\Assign( + new Expr\PropertyFetch(new Expr\Variable('this'), 'cachedTarget'), + new Expr\New_(new Name\FullyQualified(\ReflectionClass::class), [ + new Arg(new Scalar\String_($mapperMetadata->getTarget())), + ]) + )); + } + + private function supports(MapperGeneratorMetadataInterface $mapperMetadata): bool + { + if (!$mapperMetadata->targetIsAUserDefinedClass()) { + return false; + } + + $targetConstructor = $mapperMetadata->getCachedTargetReflectionClass()?->getConstructor(); + + return $targetConstructor && !$mapperMetadata->hasConstructor(); + } +} diff --git a/src/Generator/Shared/ClassDiscriminatorResolver.php b/src/Generator/Shared/ClassDiscriminatorResolver.php new file mode 100644 index 00000000..884509d7 --- /dev/null +++ b/src/Generator/Shared/ClassDiscriminatorResolver.php @@ -0,0 +1,91 @@ +targetIsAUserDefinedClass() + || !($propertyMapping = $this->propertyMapping($mapperMetadata)) + || !$propertyMapping->transformer instanceof TransformerInterface + || $propertyMapping->hasCustomTransformer() + ) { + return false; + } + + return true; + } + + public function propertyMapping(MapperGeneratorMetadataInterface $mapperMetadata): PropertyMapping|null + { + $classDiscriminatorMapping = $this->classDiscriminator?->getMappingForClass($mapperMetadata->getTarget()); + + if (!$classDiscriminatorMapping) { + return null; + } + + return $mapperMetadata->getPropertyMapping($classDiscriminatorMapping->getTypeProperty()); + } + + /** + * @return array + */ + public function discriminatorMapperNames(MapperGeneratorMetadataInterface $mapperMetadata): array + { + $classDiscriminatorMapping = $this->classDiscriminator?->getMappingForClass($mapperMetadata->getTarget()); + + if (!$classDiscriminatorMapping) { + return []; + } + + return array_combine( + array_values($classDiscriminatorMapping->getTypesMapping()), + $this->discriminatorNames($mapperMetadata, $classDiscriminatorMapping) + ); + } + + /** + * @return array + */ + public function discriminatorMapperNamesIndexedByTypeValue(MapperGeneratorMetadataInterface $mapperMetadata): array + { + $classDiscriminatorMapping = $this->classDiscriminator?->getMappingForClass($mapperMetadata->getTarget()); + + if (!$classDiscriminatorMapping) { + return []; + } + + return array_combine( + array_keys($classDiscriminatorMapping->getTypesMapping()), + $this->discriminatorNames($mapperMetadata, $classDiscriminatorMapping) + ); + } + + /** + * @return list + */ + private function discriminatorNames(MapperGeneratorMetadataInterface $mapperMetadata, ClassDiscriminatorMapping $classDiscriminatorMapping): array + { + return array_map( + static fn (string $typeTarget) => "Discriminator_Mapper_{$mapperMetadata->getSource()}_{$typeTarget}", + $classDiscriminatorMapping->getTypesMapping() + ); + } +} diff --git a/src/Generator/Shared/DiscriminatorStatementsGenerator.php b/src/Generator/Shared/DiscriminatorStatementsGenerator.php new file mode 100644 index 00000000..46a91d1c --- /dev/null +++ b/src/Generator/Shared/DiscriminatorStatementsGenerator.php @@ -0,0 +1,133 @@ + + */ + public function injectMapperStatements(MapperGeneratorMetadataInterface $mapperMetadata): array + { + if (!$this->supports($mapperMetadata)) { + return []; + } + + $discriminatorMapperNames = $this->classDiscriminatorResolver->discriminatorMapperNames($mapperMetadata); + + $injectMapperStatements = []; + + foreach ($discriminatorMapperNames as $typeTarget => $discriminatorMapperName) { + /* + * We inject dependencies for all the discriminator variant + * + * ```php + * $this->mappers['Discriminator_Mapper_VariantA'] = $autoMapperRegistry->getMapper($source, VariantA::class); + * $this->mappers['Discriminator_Mapper_VariantB'] = $autoMapperRegistry->getMapper($source, VariantB::class); + * ... + * ``` + */ + $injectMapperStatements[] = new Stmt\Expression( + new Expr\Assign( + new Expr\ArrayDimFetch( + new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), + new Scalar\String_($discriminatorMapperName) + ), + new Expr\MethodCall(new Expr\Variable('autoMapperRegistry'), 'getMapper', [ + new Arg(new Scalar\String_($mapperMetadata->getSource())), + new Arg(new Scalar\String_($typeTarget)), + ]) + ) + ); + } + + return $injectMapperStatements; + } + + /** + * @return list + * + * We return the object created with the correct mapper depending on the variant, this will skip the next mapping phase in this situation + * + * ```php + * if ('VariantA' === $output) { + * return $this->mappers['Discriminator_Mapper_VariantA']->map($source, $context); + * } + * ``` + */ + public function createTargetStatements(MapperGeneratorMetadataInterface $mapperMetadata): array + { + if (!$this->supports($mapperMetadata)) { + return []; + } + + $propertyMapping = $this->classDiscriminatorResolver->propertyMapping($mapperMetadata); + + $variableRegistry = $mapperMetadata->getVariableRegistry(); + + // Generate the code that allows to put the type into the output variable, + // so we are able to determine which mapper to use + [$output, $createObjectStatements] = $propertyMapping->transformer->transform( + $propertyMapping->readAccessor->getExpression($variableRegistry->getSourceInput()), + $variableRegistry->getResult(), + $propertyMapping, + $variableRegistry->getUniqueVariableScope() + ); + + foreach ($this->classDiscriminatorResolver->discriminatorMapperNamesIndexedByTypeValue($mapperMetadata) as $typeValue => $discriminatorMapperName) { + $createObjectStatements[] = new Stmt\If_( + new Expr\BinaryOp\Identical(new Scalar\String_($typeValue), $output), + [ + 'stmts' => [ + new Stmt\Return_( + new Expr\MethodCall( + new Expr\ArrayDimFetch( + new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), + new Scalar\String_($discriminatorMapperName) + ), + 'map', + [ + new Arg($variableRegistry->getSourceInput()), + new Arg(new Expr\Variable('context')), + ] + ) + ), + ], + ] + ); + } + + return $createObjectStatements; + } + + private function supports(MapperGeneratorMetadataInterface $mapperMetadata): bool + { + if (!$this->classDiscriminatorResolver->hasClassDiscriminator($mapperMetadata)) { + return false; + } + + $propertyMapping = $this->classDiscriminatorResolver->propertyMapping($mapperMetadata); + + return $propertyMapping && $propertyMapping->transformer instanceof TransformerInterface; + } +} diff --git a/src/Generator/VariableRegistry.php b/src/Generator/VariableRegistry.php new file mode 100644 index 00000000..9389a50d --- /dev/null +++ b/src/Generator/VariableRegistry.php @@ -0,0 +1,70 @@ +> */ + private array $fieldValueVariables = []; + + public function __construct() + { + $this->uniqueVariableScope = new UniqueVariableScope(); + + $this->sourceInput = new Variable($this->uniqueVariableScope->getUniqueName('value')); + $this->result = new Variable($this->uniqueVariableScope->getUniqueName('result')); + $this->hashVariable = new Variable($this->uniqueVariableScope->getUniqueName('sourceHash')); + $this->contextVariable = new Variable($this->uniqueVariableScope->getUniqueName('context')); + } + + public function getUniqueVariableScope(): UniqueVariableScope + { + return $this->uniqueVariableScope; + } + + public function getSourceInput(): Variable + { + return $this->sourceInput; + } + + public function getResult(): Variable + { + return $this->result; + } + + public function getHash(): Variable + { + return $this->hashVariable; + } + + public function getContext(): Variable + { + return $this->contextVariable; + } + + public function getFieldValueVariable(PropertyMapping $propertyMapping): Variable + { + return $this->fieldValueVariables[$propertyMapping->mapperMetadata->getMapperClassName()][$propertyMapping->property] + ??= $this->getVariableWithUniqueName('fieldValue'); + } + + public function getVariableWithUniqueName(string $name): Variable + { + return new Variable($this->uniqueVariableScope->getUniqueName($name)); + } +} diff --git a/src/Loader/EvalLoader.php b/src/Loader/EvalLoader.php index b3cf65b4..0c4e538f 100644 --- a/src/Loader/EvalLoader.php +++ b/src/Loader/EvalLoader.php @@ -4,7 +4,7 @@ namespace AutoMapper\Loader; -use AutoMapper\Generator\Generator; +use AutoMapper\Generator\MapperGenerator; use AutoMapper\MapperGeneratorMetadataInterface; use PhpParser\PrettyPrinter\Standard; use PhpParser\PrettyPrinterAbstract; @@ -19,7 +19,7 @@ private PrettyPrinterAbstract $printer; public function __construct( - private Generator $generator, + private MapperGenerator $generator, ) { $this->printer = new Standard(); } diff --git a/src/Loader/FileLoader.php b/src/Loader/FileLoader.php index 8d2877e8..2e3f2893 100644 --- a/src/Loader/FileLoader.php +++ b/src/Loader/FileLoader.php @@ -4,7 +4,7 @@ namespace AutoMapper\Loader; -use AutoMapper\Generator\Generator; +use AutoMapper\Generator\MapperGenerator; use AutoMapper\MapperGeneratorMetadataInterface; use PhpParser\PrettyPrinter\Standard; use PhpParser\PrettyPrinterAbstract; @@ -20,7 +20,7 @@ final class FileLoader implements ClassLoaderInterface private ?array $registry = null; public function __construct( - private readonly Generator $generator, + private readonly MapperGenerator $generator, private readonly string $directory, private readonly bool $hotReload = true, ) { diff --git a/src/MapperGeneratorMetadataInterface.php b/src/MapperGeneratorMetadataInterface.php index a811c9d8..294f4d74 100644 --- a/src/MapperGeneratorMetadataInterface.php +++ b/src/MapperGeneratorMetadataInterface.php @@ -4,6 +4,8 @@ namespace AutoMapper; +use AutoMapper\Generator\VariableRegistry; + /** * Stores metadata needed when generating a mapper. * @@ -59,4 +61,15 @@ public function canHaveCircularReference(): bool; * Whether we should map private properties and methods. */ public function shouldMapPrivateProperties(): bool; + + public function getCachedTargetReflectionClass(): \ReflectionClass|null; // @phpstan-ignore-line + + /** + * Fields to set in the constructor: this allows transforming them before the constructor is called. + * + * @return list + */ + public function getPropertiesInConstructor(): array; + + public function getVariableRegistry(): VariableRegistry; } diff --git a/src/MapperMetadata.php b/src/MapperMetadata.php index c8dc0528..5481da11 100644 --- a/src/MapperMetadata.php +++ b/src/MapperMetadata.php @@ -7,9 +7,11 @@ use AutoMapper\Extractor\MappingExtractorInterface; use AutoMapper\Extractor\PropertyMapping; use AutoMapper\Extractor\ReadAccessor; +use AutoMapper\Generator\VariableRegistry; use AutoMapper\Transformer\CallbackTransformer; use AutoMapper\Transformer\CustomTransformer\CustomPropertyTransformerInterface; use AutoMapper\Transformer\DependentTransformerInterface; +use AutoMapper\Transformer\MapperDependency; /** * Mapper metadata. @@ -30,6 +32,11 @@ class MapperMetadata implements MapperGeneratorMetadataInterface /** @var array */ private array $customMapping = []; + /** @var list|null */ + private array|null $propertiesInConstructor = null; + + private VariableRegistry $variableRegistry; + public function __construct( private readonly MapperGeneratorMetadataRegistryInterface $metadataRegistry, private readonly MappingExtractorInterface $mappingExtractor, @@ -42,14 +49,16 @@ public function __construct( $this->isConstructorAllowed = true; $this->dateTimeFormat = \DateTime::RFC3339; $this->attributeChecking = true; - } - private function getCachedTargetReflectionClass(): \ReflectionClass - { - if (null === $this->targetReflectionClass) { + if (class_exists($this->getTarget()) && $this->getTarget() !== \stdClass::class) { $this->targetReflectionClass = new \ReflectionClass($this->getTarget()); } + $this->variableRegistry = new VariableRegistry(); + } + + public function getCachedTargetReflectionClass(): \ReflectionClass|null + { return $this->targetReflectionClass; } @@ -77,8 +86,7 @@ public function hasConstructor(): bool return false; } - $reflection = $this->getCachedTargetReflectionClass(); - $constructor = $reflection->getConstructor(); + $constructor = $this->getCachedTargetReflectionClass()?->getConstructor(); if (null === $constructor) { return false; @@ -113,6 +121,10 @@ public function isTargetCloneable(): bool try { $reflection = $this->getCachedTargetReflectionClass(); + if (!$reflection) { + return false; + } + return $reflection->isCloneable() && !$reflection->hasMethod('__clone'); } catch (\ReflectionException $e) { // if we have a \ReflectionException, then we can't clone target @@ -124,7 +136,7 @@ public function canHaveCircularReference(): bool { $checked = []; - return $this->checkCircularMapperConfiguration($this, $checked); + return 'array' !== $this->getSource() && $this->checkCircularMapperConfiguration($this, $checked); } public function getMapperClassName(): string @@ -145,8 +157,7 @@ public function getHash(): string $hash .= filemtime($reflection->getFileName()); } - if (!\in_array($this->target, ['array', \stdClass::class], true)) { - $reflection = $this->getCachedTargetReflectionClass(); + if ($reflection = $this->getCachedTargetReflectionClass()) { $hash .= filemtime($reflection->getFileName()); } @@ -170,6 +181,11 @@ public function getTarget(): string return $this->target; } + public function targetIsAUserDefinedClass(): bool + { + return !\in_array($this->target, ['array', \stdClass::class], true); + } + public function getDateTimeFormat(): string { return $this->dateTimeFormat; @@ -231,6 +247,7 @@ private function buildPropertyMapping(): void foreach ($this->customMapping as $property => $callback) { $this->propertiesMapping[$property] = new PropertyMapping( + $this, new ReadAccessor(ReadAccessor::TYPE_SOURCE, $property), $this->mappingExtractor->getWriteMutator($this->source, $this->target, $property), null, @@ -271,6 +288,24 @@ private function checkCircularMapperConfiguration(MapperGeneratorMetadataInterfa return false; } + public function getAllDependencies(): array + { + /** @var list $dependencies */ + $dependencies = array_merge( + ...array_values( + array_map( + static fn (PropertyMapping $pm) => $pm->transformer instanceof DependentTransformerInterface + ? $pm->transformer->getDependencies() + : [], + $this->getPropertiesMapping() + ) + ) + ); + + // remove duplicates + return array_values(array_combine(array_column($dependencies, 'name'), $dependencies)); + } + public function isTargetReadOnlyClass(): bool { return $this->isTargetReadOnlyClass; @@ -280,4 +315,36 @@ public function shouldMapPrivateProperties(): bool { return $this->mapPrivateProperties; } + + public function getPropertiesInConstructor(): array + { + return $this->propertiesInConstructor ??= (function () { + if (\in_array($this->target, ['array', \stdClass::class])) { + return []; + } + + $targetConstructor = $this->getCachedTargetReflectionClass()?->getConstructor(); + + if (null === $targetConstructor || !$this->hasConstructor()) { + return []; + } + + $inConstructor = []; + + foreach ($this->getPropertiesMapping() as $propertyMapping) { + if (null === $propertyMapping->writeMutatorConstructor || null === $propertyMapping->writeMutatorConstructor->parameter) { + continue; + } + + $inConstructor[] = $propertyMapping->property; + } + + return $inConstructor; + })(); + } + + public function getVariableRegistry(): VariableRegistry + { + return $this->variableRegistry; + } } diff --git a/src/MapperMetadataInterface.php b/src/MapperMetadataInterface.php index a224bbe5..b52efaf6 100644 --- a/src/MapperMetadataInterface.php +++ b/src/MapperMetadataInterface.php @@ -5,6 +5,7 @@ namespace AutoMapper; use AutoMapper\Extractor\PropertyMapping; +use AutoMapper\Transformer\MapperDependency; /** * Stores metadata needed for mapping data. @@ -23,11 +24,23 @@ public function getSource(): string; */ public function getTarget(): string; + /** + * Returns true if target is an object. + */ + public function targetIsAUserDefinedClass(): bool; + /** * Check if the target is a read-only class. */ public function isTargetReadOnlyClass(): bool; + /** + * Get a set of all dependencies, deduplicated. + * + * @return list + */ + public function getAllDependencies(): array; + /** * Get properties to map between source and target. * @@ -36,7 +49,7 @@ public function isTargetReadOnlyClass(): bool; public function getPropertiesMapping(): array; /** - * Get property to map by name, or null if not mapped. + * Get propertyMapping by property name, or null if not mapped. */ public function getPropertyMapping(string $property): ?PropertyMapping; diff --git a/tests/AutoMapperBaseTest.php b/tests/AutoMapperBaseTest.php index a842a3b5..f878e529 100644 --- a/tests/AutoMapperBaseTest.php +++ b/tests/AutoMapperBaseTest.php @@ -7,10 +7,10 @@ use AutoMapper\AutoMapper; use AutoMapper\Extractor\ClassMethodToCallbackExtractor; use AutoMapper\Extractor\CustomTransformerExtractor; -use AutoMapper\Generator\Generator; +use AutoMapper\Generator\MapperGenerator; +use AutoMapper\Generator\Shared\ClassDiscriminatorResolver; use AutoMapper\Loader\ClassLoaderInterface; use AutoMapper\Loader\FileLoader; -use PhpParser\ParserFactory; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; @@ -37,10 +37,9 @@ protected function buildAutoMapper(bool $allowReadOnlyTargetToPopulate = false, $fs->remove(__DIR__ . '/cache/'); $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $this->loader = new FileLoader(new Generator( + $this->loader = new FileLoader(new MapperGenerator( new CustomTransformerExtractor(new ClassMethodToCallbackExtractor()), - (new ParserFactory())->create(ParserFactory::PREFER_PHP7), - new ClassDiscriminatorFromClassMetadata($classMetadataFactory), + new ClassDiscriminatorResolver(new ClassDiscriminatorFromClassMetadata($classMetadataFactory)), $allowReadOnlyTargetToPopulate ), __DIR__ . '/cache'); diff --git a/tests/Transformer/EvalTransformerTrait.php b/tests/Transformer/EvalTransformerTrait.php index 387c517a..2ec399b3 100644 --- a/tests/Transformer/EvalTransformerTrait.php +++ b/tests/Transformer/EvalTransformerTrait.php @@ -7,6 +7,7 @@ use AutoMapper\Extractor\PropertyMapping; use AutoMapper\Extractor\ReadAccessor; use AutoMapper\Generator\UniqueVariableScope; +use AutoMapper\MapperGeneratorMetadataInterface; use AutoMapper\Transformer\TransformerInterface; use PhpParser\Node\Expr; use PhpParser\Node\Param; @@ -19,6 +20,7 @@ private function createTransformerFunction(TransformerInterface $transformer, Pr { if (null === $propertyMapping) { $propertyMapping = new PropertyMapping( + $this->createMock(MapperGeneratorMetadataInterface::class), new ReadAccessor(ReadAccessor::TYPE_PROPERTY, 'dummy'), null, null, diff --git a/tools/phpstan/phpstan-baseline.neon b/tools/phpstan/phpstan-baseline.neon index 87024eee..0554c96e 100644 --- a/tools/phpstan/phpstan-baseline.neon +++ b/tools/phpstan/phpstan-baseline.neon @@ -246,39 +246,34 @@ parameters: path: ../../src/GeneratedMapper.php - - message: "#^Cannot call method getExpression\\(\\) on null\\.$#" + message: "#^Parameter \\#2 \\$args of class PhpParser\\\\Node\\\\Expr\\\\New_ constructor expects array\\, array\\ given\\.$#" count: 1 - path: ../../src/Generator/Generator.php + path: ../../src/Generator/CreateTargetStatementsGenerator.php - - message: "#^Method AutoMapper\\\\Generator\\\\Generator\\:\\:getCreateObjectStatements\\(\\) return type has no value type specified in iterable type array\\.$#" + message: "#^Parameter \\#2 \\$constructArguments of method AutoMapper\\\\Generator\\\\CreateTargetStatementsGenerator\\:\\:constructorArgumentWithDefaultValue\\(\\) expects array\\, array\\ given\\.$#" count: 1 - path: ../../src/Generator/Generator.php + path: ../../src/Generator/CreateTargetStatementsGenerator.php - - message: "#^Method AutoMapper\\\\Generator\\\\Generator\\:\\:getValueAsExpr\\(\\) has no return type specified\\.$#" + message: "#^Cannot access property \\$readAccessor on AutoMapper\\\\Extractor\\\\PropertyMapping\\|null\\.$#" count: 1 - path: ../../src/Generator/Generator.php + path: ../../src/Generator/Shared/DiscriminatorStatementsGenerator.php - - message: "#^Method AutoMapper\\\\Generator\\\\Generator\\:\\:getValueAsExpr\\(\\) has parameter \\$value with no type specified\\.$#" + message: "#^Cannot access property \\$transformer on AutoMapper\\\\Extractor\\\\PropertyMapping\\|null\\.$#" count: 1 - path: ../../src/Generator/Generator.php + path: ../../src/Generator/Shared/DiscriminatorStatementsGenerator.php - - message: "#^Offset 0 does not exist on array\\\\|null\\.$#" + message: "#^Cannot call method getExpression\\(\\) on AutoMapper\\\\Extractor\\\\ReadAccessor\\|null\\.$#" count: 1 - path: ../../src/Generator/Generator.php + path: ../../src/Generator/Shared/DiscriminatorStatementsGenerator.php - - message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\\\|T of object, string given\\.$#" + message: "#^Cannot call method transform\\(\\) on AutoMapper\\\\Transformer\\\\TransformerInterface\\|class\\-string\\\\.$#" count: 1 - path: ../../src/Generator/Generator.php - - - - message: "#^Parameter \\#3 \\$args of class PhpParser\\\\Node\\\\Expr\\\\MethodCall constructor expects array\\, array\\ given\\.$#" - count: 1 - path: ../../src/Generator/Generator.php + path: ../../src/Generator/Shared/DiscriminatorStatementsGenerator.php - message: "#^Method AutoMapper\\\\Loader\\\\FileLoader\\:\\:addHashToRegistry\\(\\) has parameter \\$className with no type specified\\.$#" @@ -460,11 +455,6 @@ parameters: count: 2 path: ../../src/MapperMetadata.php - - - message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\\\|T of object, string given\\.$#" - count: 1 - path: ../../src/MapperMetadata.php - - message: "#^Property AutoMapper\\\\MapperMetadata\\:\\:\\$targetReflectionClass with generic class ReflectionClass does not specify its types\\: T$#" count: 1