Skip to content

Commit 0f7c097

Browse files
committed
Generic AST extractor
1 parent 60ecb7e commit 0f7c097

11 files changed

+266
-36
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
### Added
10+
- [GH#22](https://github.com/jolicode/automapper/pull/22) Added generic AST extractor
911

1012
## [8.0.2] - 2023-11-06
1113
### Added

src/Exception/CircularReferenceException.php

-9
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,6 @@
22

33
declare(strict_types=1);
44

5-
/*
6-
* This file is part of the Symfony package.
7-
*
8-
* (c) Fabien Potencier <[email protected]>
9-
*
10-
* For the full copyright and license information, please view the LICENSE
11-
* file that was distributed with this source code.
12-
*/
13-
145
namespace AutoMapper\Exception;
156

167
/**

src/Exception/CompileException.php

-9
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,6 @@
22

33
declare(strict_types=1);
44

5-
/*
6-
* This file is part of the Symfony package.
7-
*
8-
* (c) Fabien Potencier <[email protected]>
9-
*
10-
* For the full copyright and license information, please view the LICENSE
11-
* file that was distributed with this source code.
12-
*/
13-
145
namespace AutoMapper\Exception;
156

167
/**
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Exception;
6+
7+
/**
8+
* @author Baptiste Leduc <[email protected]>
9+
*/
10+
final class InvalidArgumentException extends \InvalidArgumentException
11+
{
12+
}

src/Exception/InvalidMappingException.php

-9
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,6 @@
22

33
declare(strict_types=1);
44

5-
/*
6-
* This file is part of the Symfony package.
7-
*
8-
* (c) Fabien Potencier <[email protected]>
9-
*
10-
* For the full copyright and license information, please view the LICENSE
11-
* file that was distributed with this source code.
12-
*/
13-
145
namespace AutoMapper\Exception;
156

167
/**

src/Exception/LogicException.php

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Exception;
6+
7+
/**
8+
* @author Baptiste Leduc <[email protected]>
9+
*/
10+
final class LogicException extends \LogicException
11+
{
12+
}

src/Exception/NoMappingFoundException.php

-9
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,6 @@
22

33
declare(strict_types=1);
44

5-
/*
6-
* This file is part of the Symfony package.
7-
*
8-
* (c) Fabien Potencier <[email protected]>
9-
*
10-
* For the full copyright and license information, please view the LICENSE
11-
* file that was distributed with this source code.
12-
*/
13-
145
namespace AutoMapper\Exception;
156

167
/**

src/Extractor/AstExtractor.php

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Extractor;
6+
7+
use AutoMapper\Exception\InvalidArgumentException;
8+
use AutoMapper\Exception\LogicException;
9+
use AutoMapper\Exception\RuntimeException;
10+
use PhpParser\Node\Arg;
11+
use PhpParser\Node\Expr;
12+
use PhpParser\Node\Identifier;
13+
use PhpParser\Node\Param;
14+
use PhpParser\Node\Stmt;
15+
use PhpParser\Parser;
16+
use PhpParser\ParserFactory;
17+
18+
/**
19+
* @author Nicolas Philippe <[email protected]>
20+
* @author Baptiste Leduc <[email protected]>
21+
*
22+
* @internal
23+
*/
24+
final readonly class AstExtractor
25+
{
26+
private Parser $parser;
27+
28+
public function __construct(?Parser $parser = null)
29+
{
30+
$this->parser = $parser ?? (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
31+
}
32+
33+
/**
34+
* Extracts the code of the given method from a given class, and wraps it inside a closure, in order to inject it
35+
* in the generated mappers.
36+
*
37+
* @param class-string $class
38+
* @param Arg[] $inputParameters
39+
*/
40+
public function extract(string $class, string $method, array $inputParameters): Expr
41+
{
42+
$fileName = (new \ReflectionClass($class))->getFileName();
43+
if (false === $fileName) {
44+
throw new RuntimeException("You cannot extract code from \"{$class}\" class.");
45+
}
46+
$fileContents = file_get_contents($fileName);
47+
if (false === $fileContents) {
48+
throw new RuntimeException("File \"{$fileName}\" for \"{$class}\" couldn't be read.");
49+
}
50+
51+
$statements = $this->parser->parse($fileContents);
52+
if (null === $statements) {
53+
throw new RuntimeException("Couldn't parse file \"{$fileName}\" for class \"{$class}\".");
54+
}
55+
56+
$namespaceStatement = self::findUnique(Stmt\Namespace_::class, $statements, $fileName);
57+
/** @var Stmt\Class_ $classStatement */
58+
$classStatement = self::findUnique(Stmt\Class_::class, $namespaceStatement->stmts, $fileName);
59+
60+
$classMethod = $classStatement->getMethod($method) ?? throw new LogicException("Cannot find method \"{$method}()\" in class \"{$class}\".");
61+
62+
if (\count($inputParameters) !== \count($classMethod->getParams())) {
63+
throw new InvalidArgumentException("Input parameters and method parameters in class \"{$class}\" do not match.");
64+
}
65+
66+
foreach ($classMethod->getParams() as $key => $parameter) {
67+
/** @var Expr\Variable $inputParameterValue */
68+
$inputParameterValue = $inputParameters[$key]->value;
69+
70+
if ($parameter->var instanceof Expr\Variable && $inputParameterValue->name !== $parameter->var->name) {
71+
$parameterName = \is_string($parameter->var->name) ? $parameter->var->name : 'N/A';
72+
throw new InvalidArgumentException("Method parameter \"{$parameterName}\" does not match type \"{$inputParameters[$key]->getType()}\" from input parameter \"{$inputParameters[$key]->name}\" in \"{$class}::{$method}\" method.");
73+
}
74+
}
75+
76+
$closureParameters = [];
77+
foreach ($classMethod->getParams() as $parameter) {
78+
if ($parameter->var instanceof Expr\Variable && $parameter->type instanceof Identifier) {
79+
$closureParameters[] = new Param(new Expr\Variable($parameter->var->name), type: $parameter->type->name);
80+
}
81+
}
82+
83+
return new Expr\FuncCall(
84+
new Expr\Closure([
85+
'stmts' => $classMethod->stmts,
86+
'params' => $closureParameters,
87+
'returnType' => $classMethod->returnType,
88+
]),
89+
$inputParameters,
90+
);
91+
}
92+
93+
/**
94+
* @template T of Stmt
95+
*
96+
* @param class-string<T> $searchedStatementClass
97+
* @param Stmt[] $statements
98+
*
99+
* @return T
100+
*/
101+
private static function findUnique(string $searchedStatementClass, array $statements, string $fileName): Stmt
102+
{
103+
$foundStatements = array_filter(
104+
$statements,
105+
static fn (Stmt $statement): bool => $statement instanceof $searchedStatementClass,
106+
);
107+
108+
if (\count($foundStatements) > 1) {
109+
throw new InvalidArgumentException("Multiple \"{$searchedStatementClass}\" found in file \"{$fileName}\".");
110+
}
111+
112+
return array_values($foundStatements)[0] ?? throw new InvalidArgumentException("No \"{$searchedStatementClass}\" found in file \"{$fileName}\".");
113+
}
114+
}

tests/Extractor/AstExtractorTest.php

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Tests\Extractor;
6+
7+
use AutoMapper\Exception\InvalidArgumentException;
8+
use AutoMapper\Exception\RuntimeException;
9+
use AutoMapper\Extractor\AstExtractor;
10+
use AutoMapper\Tests\Extractor\Fixtures\Foo;
11+
use AutoMapper\Tests\Extractor\Fixtures\FooCustomMapper;
12+
use PhpParser\Node\Arg;
13+
use PhpParser\Node\Expr\Variable;
14+
use PhpParser\Node\Stmt\Expression;
15+
use PhpParser\PrettyPrinter\Standard;
16+
use PHPUnit\Framework\TestCase;
17+
18+
/**
19+
* @author Baptiste Leduc <[email protected]>
20+
*/
21+
class AstExtractorTest extends TestCase
22+
{
23+
public function testExtractSimpleMethod(): void
24+
{
25+
$extractor = new AstExtractor();
26+
$extractedMethod = new Expression($extractor->extract(FooCustomMapper::class, 'transform', [new Arg(new Variable('object'))]));
27+
28+
$this->assertEquals(<<<PHP
29+
(function (mixed \$object) : mixed {
30+
if (\$object instanceof Foo) {
31+
\$object->bar = 'Hello World!';
32+
}
33+
return \$object;
34+
})(\$object);
35+
PHP, $generatedCode = (new Standard())->prettyPrint([$extractedMethod]));
36+
37+
$codeToEval = <<<PHP
38+
class Foo
39+
{
40+
public string \$bar;
41+
public string \$baz;
42+
}
43+
44+
\$object = new Foo();
45+
\$object->bar = 'Hello';
46+
47+
{$generatedCode}
48+
49+
return \$object;
50+
PHP;
51+
52+
/** @var Foo $object */
53+
$object = eval($codeToEval);
54+
$this->assertEquals('Hello World!', $object->bar);
55+
}
56+
57+
public function testCannotExtractCode(): void
58+
{
59+
$coreClass = \Generator::class;
60+
61+
$this->expectException(RuntimeException::class);
62+
$this->expectExceptionMessage("You cannot extract code from \"{$coreClass}\" class.");
63+
64+
$extractor = new AstExtractor();
65+
$extractor->extract($coreClass, 'rewind', [new Arg(new Variable('object'))]);
66+
}
67+
68+
public function testInvalidInputParameters(): void
69+
{
70+
$class = FooCustomMapper::class;
71+
72+
$this->expectException(InvalidArgumentException::class);
73+
$this->expectExceptionMessage("Input parameters and method parameters in class \"{$class}\" do not match.");
74+
75+
$extractor = new AstExtractor();
76+
$extractor->extract($class, 'transform', [new Arg(new Variable('object')), new Arg(new Variable('context'))]);
77+
}
78+
79+
public function testInvalidExtractedMethodParameters(): void
80+
{
81+
$class = FooCustomMapper::class;
82+
83+
$this->expectException(InvalidArgumentException::class);
84+
$this->expectExceptionMessage("Input parameters and method parameters in class \"{$class}\" do not match.");
85+
86+
$extractor = new AstExtractor();
87+
$extractor->extract($class, 'switch', [new Arg(new Variable('object'))]);
88+
}
89+
}

tests/Extractor/Fixtures/Foo.php

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Tests\Extractor\Fixtures;
6+
7+
class Foo
8+
{
9+
public string $bar;
10+
public string $baz;
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Tests\Extractor\Fixtures;
6+
7+
class FooCustomMapper
8+
{
9+
public function transform(mixed $object): mixed
10+
{
11+
if ($object instanceof Foo) {
12+
$object->bar = 'Hello World!';
13+
}
14+
15+
return $object;
16+
}
17+
18+
public function switch(mixed $object, array $context): mixed
19+
{
20+
if ($object instanceof Foo) {
21+
$object->bar = 'Hello World!';
22+
}
23+
24+
return $object;
25+
}
26+
}

0 commit comments

Comments
 (0)