Skip to content

Commit

Permalink
feat: introduce attribute #[MapTo]
Browse files Browse the repository at this point in the history
  • Loading branch information
nikophil committed Jan 12, 2024
1 parent 9e3d086 commit 663c888
Show file tree
Hide file tree
Showing 34 changed files with 412 additions and 110 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ composer.lock
.phpunit.result.cache
tests/cache/
tools/phpstan/cache/

# dont commit this
src/CustomTransformer/generated_transformers
4 changes: 2 additions & 2 deletions docs/homepage.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The mapping process could be extended in multiple ways.

#### Map manually a single property

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

```php
Expand All @@ -107,7 +107,7 @@ class BirthDateUserTransformer implements CustomPropertyTransformerInterface

#### Map manually a whole object

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

```php
Expand Down
20 changes: 20 additions & 0 deletions src/Attribute/MapTo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Attribute;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final readonly class MapTo
{
/**
* @param non-empty-string $propertyName
* @param 'array'|class-string|null $target
*/
public function __construct(
public string $propertyName,
public string|null $target = null,
)
{
}
}
4 changes: 2 additions & 2 deletions src/AutoMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace AutoMapper;

use AutoMapper\CustomTransformer\CustomTransformerInterface;
use AutoMapper\CustomTransformer\CustomTransformersRegistry;
use AutoMapper\Exception\NoMappingFoundException;
use AutoMapper\Extractor\ClassMethodToCallbackExtractor;
use AutoMapper\Extractor\CustomTransformerExtractor;
Expand All @@ -17,8 +19,6 @@
use AutoMapper\Transformer\ArrayTransformerFactory;
use AutoMapper\Transformer\BuiltinTransformerFactory;
use AutoMapper\Transformer\ChainTransformerFactory;
use AutoMapper\Transformer\CustomTransformer\CustomTransformerInterface;
use AutoMapper\Transformer\CustomTransformer\CustomTransformersRegistry;
use AutoMapper\Transformer\DateTimeTransformerFactory;
use AutoMapper\Transformer\EnumTransformerFactory;
use AutoMapper\Transformer\MultipleTransformerFactory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace AutoMapper\Transformer\CustomTransformer;
namespace AutoMapper\CustomTransformer;

use Symfony\Component\PropertyInfo\Type;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace AutoMapper\Transformer\CustomTransformer;
namespace AutoMapper\CustomTransformer;

/**
* This interface should be implemented to handle custom transformations for specific property,
Expand Down
144 changes: 144 additions & 0 deletions src/CustomTransformer/CustomTransformerGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace AutoMapper\CustomTransformer;

use AutoMapper\Extractor\AstExtractor;
use AutoMapper\Extractor\ReadAccessor;
use PhpParser\Builder;
use PhpParser\Node\Expr;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt;
use PhpParser\PrettyPrinter\Standard;

final readonly class CustomTransformerGenerator
{
private AstExtractor $astExtractor;

public function __construct(
private CustomTransformersRegistry $customTransformerRegistry,
AstExtractor|null $astExtractor = null
) {
$this->astExtractor = $astExtractor ?? new AstExtractor();
}

/**
* @return class-string<CustomTransformerInterface>
*/
public function generateMapToCustomTransformer(
string $source,
string $target,
string $sourceProperty,
string $targetProperty,
ReadAccessor $readAccessor
): string {
$transformerClass = strtr('MapTo_Transformer_{source}_{target}_{sourceProperty}_{targetProperty}', [
'{source}' => str_replace('\\', '_', $source),
'{target}' => str_replace('\\', '_', $target),
'{sourceProperty}' => $sourceProperty,
'{targetProperty}' => $targetProperty,
]);

$file = __DIR__ . "/{$transformerClass}.php";

if (class_exists($transformerClass)) {
$this->customTransformerRegistry->addCustomTransformer(new $transformerClass());

Check failure on line 46 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $customTransformer of method AutoMapper\CustomTransformer\CustomTransformersRegistry::addCustomTransformer() expects AutoMapper\CustomTransformer\CustomTransformerInterface, object given.

Check failure on line 46 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $customTransformer of method AutoMapper\CustomTransformer\CustomTransformersRegistry::addCustomTransformer() expects AutoMapper\CustomTransformer\CustomTransformerInterface, object given.

return $transformerClass;

Check failure on line 48 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Method AutoMapper\CustomTransformer\CustomTransformerGenerator::generateMapToCustomTransformer() should return class-string<AutoMapper\CustomTransformer\CustomTransformerInterface> but returns class-string.

Check failure on line 48 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Method AutoMapper\CustomTransformer\CustomTransformerGenerator::generateMapToCustomTransformer() should return class-string<AutoMapper\CustomTransformer\CustomTransformerInterface> but returns class-string.
}

if (!file_exists($file)) {
$class = (new Builder\Class_($transformerClass))
->makeFinal()
->makeReadonly()
->implement(CustomPropertyTransformerInterface::class)
->addStmt($this->generateSupportsStatement($source, $target, $targetProperty))
->addStmt($this->generateTransformStatement($readAccessor, $source))
->getNode();

$code = "<?php\n" . (new Standard())->prettyPrint([$class]);
file_put_contents($file = __DIR__ . "/generated_transformers/{$transformerClass}.php", $code);
}

require_once $file;

$this->customTransformerRegistry->addCustomTransformer(new $transformerClass());

Check failure on line 66 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $customTransformer of method AutoMapper\CustomTransformer\CustomTransformersRegistry::addCustomTransformer() expects AutoMapper\CustomTransformer\CustomTransformerInterface, object given.

Check failure on line 66 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $customTransformer of method AutoMapper\CustomTransformer\CustomTransformersRegistry::addCustomTransformer() expects AutoMapper\CustomTransformer\CustomTransformerInterface, object given.

return $transformerClass;

Check failure on line 68 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Method AutoMapper\CustomTransformer\CustomTransformerGenerator::generateMapToCustomTransformer() should return class-string<AutoMapper\CustomTransformer\CustomTransformerInterface> but returns string.

Check failure on line 68 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Method AutoMapper\CustomTransformer\CustomTransformerGenerator::generateMapToCustomTransformer() should return class-string<AutoMapper\CustomTransformer\CustomTransformerInterface> but returns string.
}

/**
* public function supports(string $source, string $target, string $propertyName): bool
* {
* return $source === [source type] && $target === [target type] && $propertyName === [target property];
* }
*/
private function generateSupportsStatement(string $source, string $target, string $targetProperty): Stmt\ClassMethod
{
$supportsMethodInInterface = $this->astExtractor
->extractClassLike(CustomPropertyTransformerInterface::class)
->getMethod('supports') ?? throw new \LogicException(
'Cannot find "supports" method in interface ' . CustomPropertyTransformerInterface::class
);

return (new Builder\Method('supports'))
->makePublic()
->setReturnType($supportsMethodInInterface->getReturnType())

Check failure on line 87 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $type of method PhpParser\Builder\FunctionLike::setReturnType() expects PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|string, PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|null given.

Check failure on line 87 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $type of method PhpParser\Builder\FunctionLike::setReturnType() expects PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|string, PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|null given.
->addParams($supportsMethodInInterface->getParams())
->addStmt(
new Stmt\Return_(
new Expr\BinaryOp\BooleanAnd(
new Expr\BinaryOp\Identical(
$supportsMethodInInterface->getParams()[0]->var,
new String_($source)
),
new Expr\BinaryOp\BooleanAnd(
new Expr\BinaryOp\Identical(
$supportsMethodInInterface->getParams()[1]->var,
new String_($target)
),
new Expr\BinaryOp\Identical(
$supportsMethodInInterface->getParams()[2]->var,
new String_($targetProperty)
),
)
)
)
)
->getNode();
}

/**
* public function transform(object|array $source): mixed
* {
* return $source->[sourceProperty accessor];
* }
*/
private function generateTransformStatement(ReadAccessor $readAccessor, string $source): Stmt\ClassMethod
{
$transformMethodInInterface = $this->astExtractor
->extractClassLike(CustomTransformerInterface::class)
->getMethod('transform') ?? throw new \LogicException(
'Cannot find "transform" method in interface ' . CustomTransformerInterface::class
);

return (new Builder\Method('transform'))
->makePublic()
->setReturnType($transformMethodInInterface->getReturnType())

Check failure on line 128 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $type of method PhpParser\Builder\FunctionLike::setReturnType() expects PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|string, PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|null given.

Check failure on line 128 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $type of method PhpParser\Builder\FunctionLike::setReturnType() expects PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|string, PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|null given.
->setDocComment(
<<<PHPDOC
/**
* @param $source \$source
*/
PHPDOC
)
->addParams($transformMethodInInterface->getParams())
->addStmt(
new Stmt\Return_(
$readAccessor->getExpression($transformMethodInInterface->getParams()[0]->var)

Check failure on line 139 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $input of method AutoMapper\Extractor\ReadAccessor::getExpression() expects PhpParser\Node\Expr\Variable, PhpParser\Node\Expr\Error|PhpParser\Node\Expr\Variable given.

Check failure on line 139 in src/CustomTransformer/CustomTransformerGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $input of method AutoMapper\Extractor\ReadAccessor::getExpression() expects PhpParser\Node\Expr\Variable, PhpParser\Node\Expr\Error|PhpParser\Node\Expr\Variable given.
)
)
->getNode();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace AutoMapper\Transformer\CustomTransformer;
namespace AutoMapper\CustomTransformer;

/**
* @experimental
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace AutoMapper\Transformer\CustomTransformer;
namespace AutoMapper\CustomTransformer;

use AutoMapper\MapperMetadataInterface;
use Symfony\Component\PropertyInfo\Type;
Expand All @@ -22,6 +22,7 @@ public function addCustomTransformer(CustomTransformerInterface $customTransform
{
if (!\in_array($customTransformer, $this->customTransformers, true)) {
$this->customTransformers[] = $customTransformer;
$this->prioritizedCustomTransformers = null; // reset priority computation
}
}

Expand All @@ -46,7 +47,7 @@ public function getCustomTransformerClass(MapperMetadataInterface $mapperMetadat
}

/**
* @return list<CustomTransformerInterface>
* @return CustomTransformerInterface
*/
private function prioritizedCustomTransformers(): array

Check failure on line 52 in src/CustomTransformer/CustomTransformersRegistry.php

View workflow job for this annotation

GitHub Actions / phpstan

Method AutoMapper\CustomTransformer\CustomTransformersRegistry::prioritizedCustomTransformers() return type has no value type specified in iterable type array.

Check failure on line 52 in src/CustomTransformer/CustomTransformersRegistry.php

View workflow job for this annotation

GitHub Actions / phpstan

Method AutoMapper\CustomTransformer\CustomTransformersRegistry::prioritizedCustomTransformers() return type has no value type specified in iterable type array.
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace AutoMapper\Transformer\CustomTransformer;
namespace AutoMapper\CustomTransformer;

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

declare(strict_types=1);

namespace AutoMapper\Extractor;

use AutoMapper\Exception\InvalidArgumentException;
use AutoMapper\Exception\RuntimeException;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser;
use PhpParser\ParserFactory;

final readonly class AstExtractor
{
private Parser $parser;

public function __construct(?Parser $parser = null)
{
$this->parser = $parser ?? (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
}

public function extractClassLike(string $class, bool $resolveImports = false): Node\Stmt\ClassLike
{
$fileName = (new \ReflectionClass($class))->getFileName();
if (false === $fileName) {
throw new RuntimeException("You cannot extract code from \"{$class}\" class.");
}
$fileContents = file_get_contents($fileName);
if (false === $fileContents) {
throw new RuntimeException("File \"{$fileName}\" for \"{$class}\" couldn't be read.");
}

$statements = $this->parser->parse($fileContents);

if (null === $statements) {
throw new RuntimeException("Couldn't parse file \"{$fileName}\" for class \"{$class}\".");
}

if ($resolveImports) {
$statements = $this->resolveFullyQualifiedClassNames($statements);
}

try {
$namespaceStatement = self::findUnique(Node\Stmt\Namespace_::class, $statements, $fileName);
} catch (\Exception) {
// if no namespace, directly search for a class
return self::findUnique(Node\Stmt\ClassLike::class, $statements, $fileName);
}

return self::findUnique(Node\Stmt\ClassLike::class, $namespaceStatement->stmts, $fileName);
}

/**
* @template T of Node
*
* @param class-string<T> $searchedStatementClass
* @param Node[] $statements
*
* @return T
*/
private static function findUnique(string $searchedStatementClass, array $statements, string $fileName): Node
{
$foundStatements = array_filter(
$statements,
static fn(Node $statement): bool => $statement instanceof $searchedStatementClass,
);

if (\count($foundStatements) > 1) {
throw new InvalidArgumentException("Multiple \"{$searchedStatementClass}\" found in file \"{$fileName}\".");
}

return array_values($foundStatements)[0] ?? throw new InvalidArgumentException(
"No \"{$searchedStatementClass}\" found in file \"{$fileName}\"."
);
}

/**
* Transform all statements with imported class names, into FQCNs.
*
* @param Node[] $statements
*
* @return Node[]
*/
private function resolveFullyQualifiedClassNames(array $statements): array
{
$nodeTraverser = new NodeTraverser();
$nodeTraverser->addVisitor(new NameResolver());

return $nodeTraverser->traverse($statements);
}
}
Loading

0 comments on commit 663c888

Please sign in to comment.