diff --git a/composer.json b/composer.json index acc6bd5..50311e0 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ } ], "require": { - "php": "^8.1" + "php": "^8.1", + "nikic/php-parser": "^5.0" }, "require-dev": { "jerodev/code-styles": "dev-master", diff --git a/composer.lock b/composer.lock index ec67c5c..e3b190e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,67 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7b010f8270a95cc28b36c706e8fddfeb", - "packages": [], + "content-hash": "608627dcf3c552025ef083ff08122f6e", + "packages": [ + { + "name": "nikic/php-parser", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.0" + }, + "time": "2024-01-07T17:17:35+00:00" + } + ], "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -250,62 +309,6 @@ ], "time": "2023-03-08T13:26:56+00:00" }, - { - "name": "nikic/php-parser", - "version": "v4.18.0", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/1bcbb2179f97633e98bbbc87044ee2611c7d7999", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": ">=7.0" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" - ], - "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.18.0" - }, - "time": "2023-12-10T21:03:43+00:00" - }, { "name": "phar-io/manifest", "version": "2.0.3", diff --git a/src/Objects/ObjectMapper.php b/src/Objects/ObjectMapper.php index ee2e4d8..deca714 100644 --- a/src/Objects/ObjectMapper.php +++ b/src/Objects/ObjectMapper.php @@ -9,6 +9,27 @@ use Jerodev\DataMapper\Types\DataType; use Jerodev\DataMapper\Types\DataTypeCollection; use Jerodev\DataMapper\Types\DataTypeFactory; +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrayDimFetch; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\BinaryOp\BooleanAnd; +use PhpParser\Node\Expr\BinaryOp\Coalesce; +use PhpParser\Node\Expr\BinaryOp\Identical; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\Ternary; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; +use PhpParser\Node\Param; +use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt; +use PhpParser\PrettyPrinter\Standard; use ReflectionClass; class ObjectMapper @@ -16,12 +37,14 @@ class ObjectMapper private const MAPPER_FUNCTION_PREFIX = 'jmapper_'; private readonly ClassBluePrinter $classBluePrinter; + private readonly Standard $prettyPrinter; public function __construct( private readonly Mapper $mapper, private readonly DataTypeFactory $dataTypeFactory = new DataTypeFactory(), ) { $this->classBluePrinter = new ClassBluePrinter(); + $this->prettyPrinter = new Standard(); } /** @@ -91,21 +114,47 @@ public function mapperDirectory(): string private function createObjectMappingFunction(ClassBluePrint $blueprint, string $mapFunctionName, bool $isNullable): string { - $tab = ' '; - $content = ''; + /** + * This array will contain all statements that will be part of the function. + * @var array $ast + */ + $ast = []; if ($isNullable) { - $content .= $tab . $tab . 'if ($data === [] && $mapper->config->nullObjectFromEmptyArray) {' . \PHP_EOL; - $content .= $tab . $tab . $tab . 'return null;' . \PHP_EOL; - $content .= $tab . $tab . '}' . \PHP_EOL . \PHP_EOL; + $ast[] = new Stmt\If_( + new BooleanAnd( + new Identical( + new Variable('data'), + new Array_(attributes: ['kind' => Array_::KIND_SHORT]), + ), + new PropertyFetch( + new PropertyFetch( + new Variable('mapper'), + 'config', + ), + 'nullObjectFromEmptyArray', + ), + ), + [ + 'stmts' => [ + new Stmt\Return_(new ConstFetch(new Name('null'))), + ], + ], + ); } // Instantiate a new object $args = []; foreach ($blueprint->constructorArguments as $name => $argument) { - $arg = "\$data['{$name}']"; + $arg = new ArrayDimFetch( + new Variable('data'), + new String_($name), + ); if ($argument['type']->isNullable()) { - $arg = "({$arg} ?? null)"; + $arg = new Coalesce( + $arg, + new ConstFetch(new Name('null')), + ); } if ($argument['type'] !== null) { @@ -118,135 +167,263 @@ private function createObjectMappingFunction(ClassBluePrint $blueprint, string $ $args[] = $arg; } - $content .= $tab . $tab . '$x = new ' . $blueprint->namespacedClassName . '(' . \implode(', ', $args) . ');'; + $ast[] = new Stmt\Expression( + new Assign( + new Variable('x'), + new New_( + new Name($blueprint->namespacedClassName), + $args, + ), + ), + ); // Map properties foreach ($blueprint->properties as $name => $property) { // Use a foreach to map key/value arrays if (\count($property['type']->types) === 1 && $property['type']->types[0]->isArray() && \count($property['type']->types[0]->genericTypes) === 2) { - $content .= $this->buildPropertyForeachMapping($name, $property, $blueprint); + $ast = \array_merge($ast, $this->buildPropertyForeachMapping($name, $property, $blueprint)); continue; } - $propertyName = "\$data['{$name}']"; + $value = new ArrayDimFetch( + new Variable('data'), + new String_($name), + ); if ($property['type']->isNullable()) { - $propertyName = "({$propertyName} ?? null)"; + $value = new Coalesce( + $value, + new ConstFetch(new Name('null')), + ); } - $propertyMap = $this->castInMapperFunction($propertyName, $property['type'], $blueprint); + $value = $this->castInMapperFunction($value, $property['type'], $blueprint); if (\array_key_exists('default', $property)) { - $propertyMap = $this->wrapDefault($propertyMap, $name, $property['default']); + $value = $this->wrapDefault($value, $name, $property['default']); } - $propertySet = \PHP_EOL . $tab . $tab . '$x->' . $name . ' = ' . $propertyMap . ';'; + $value = new Assign( + new PropertyFetch( + new Variable('x'), + $name, + ), + $value, + ); if ($this->mapper->config->allowUninitializedFields && ! \array_key_exists('default', $property)) { - $propertySet = $this->wrapArrayKeyExists($propertySet, $name); + $value = $this->wrapArrayKeyExists($value, $name); } - $content .= $propertySet; + if ($value instanceof Expr) { + $ast[] = new Stmt\Expression($value); + } else { + $ast[] = $value; + } } // Post mapping functions? foreach ($blueprint->classAttributes as $attribute) { if ($attribute instanceof PostMapping) { if (\is_string($attribute->postMappingCallback)) { - $content.= \PHP_EOL . \PHP_EOL . $tab . $tab . "\$x->{$attribute->postMappingCallback}(\$data, \$x);"; + $ast[] = new Stmt\Expression( + new Expr\MethodCall( + new Variable('x'), + $attribute->postMappingCallback, + [ + new Arg(new Variable('data')), + new Arg(new Variable('x')), + ], + ), + ); } else { - $content.= \PHP_EOL . \PHP_EOL . $tab . $tab . "\call_user_func({$attribute->postMappingCallback}, \$data, \$x);"; + $ast[] = new Stmt\Expression( + new FuncCall( + new Name\FullyQualified('call_user_func'), + [ + new Arg(new ConstFetch(new Name($attribute->postMappingCallback))), + new Arg(new Variable('data')), + new Arg(new Variable('x')), + ], + ), + ); } } } - // Render the function - $mapperClass = Mapper::class; - return << [ + new Stmt\Function_( + new Identifier($mapFunctionName), + [ + 'params' => [ + new Param(new Variable('mapper'), null, new Name\FullyQualified(Mapper::class)), + new Param(new Variable('data')), + ], + 'stmts' => $ast, + ], + ), + ], + ], + ); + + return 'prettyPrinter->prettyPrint([$tree]); } - private function castInMapperFunction(string $propertyName, DataTypeCollection $type, ClassBluePrint $bluePrint): string + private function castInMapperFunction(Expr $value, DataTypeCollection $type, ClassBluePrint $bluePrint): Expr { if (\count($type->types) === 1) { $type = $type->types[0]; if ($type->isNullable) { - return "{$propertyName} === null ? null : " . $this->castInMapperFunction($propertyName, new DataTypeCollection([$type->removeNullable()]), $bluePrint); + return new Ternary( + new Identical( + $value, + new ConstFetch(new Name('null')), + ), + new ConstFetch(new Name('null')), + $this->castInMapperFunction($value, new DataTypeCollection([$type->removeNullable()]), $bluePrint), + ); } if ($type->isNative()) { return match ($type->type) { - 'null' => 'null', - 'bool' => "\\filter_var({$propertyName}, \FILTER_VALIDATE_BOOL)", - 'float' => '(float) ' . $propertyName, - 'int' => '(int) ' . $propertyName, - 'string' => '(string) ' . $propertyName, - 'object' => '(object) ' . $propertyName, - default => $propertyName, + 'null' => new ConstFetch(new Name('null')), + 'bool' => new FuncCall(new Name\FullyQualified('filter_var'), [$value, new ConstFetch(new Name\FullyQualified('FILTER_VALIDATE_BOOL'))]), + 'float' => new Expr\Cast\Double($value, ['kind' => Expr\Cast\Double::KIND_FLOAT]), + 'int' => new Expr\Cast\Int_($value), + 'string' => new Expr\Cast\String_($value), + 'object' => new Expr\Cast\Object_($value), + default => $value, }; } if ($type->isArray()) { if ($type->isGenericArray()) { - return '(array) ' . $propertyName; + return new Expr\Cast\Array_($value); } if (\count($type->genericTypes) === 1) { - $uniqid = \uniqid(); - return "\\array_map(static fn (\$x{$uniqid}) => " . $this->castInMapperFunction('$x' . $uniqid, $type->genericTypes[0], $bluePrint) . ", {$propertyName})"; + $uniqid = \uniqid('x'); + return new FuncCall( + new Name\FullyQualified('array_map'), + [ + new Arg( + new Expr\ArrowFunction( + [ + 'params' => [ + new Param(new Variable($uniqid)), + ], + 'expr' => $this->castInMapperFunction(new Variable($uniqid), $type->genericTypes[0], $bluePrint), + ], + ), + ), + new Arg($value), + ], + ); } } if (\is_subclass_of($type->type, \BackedEnum::class)) { $enumFunction = $this->mapper->config->enumTryFrom ? 'tryFrom' : 'from'; - return "{$type->type}::{$enumFunction}({$propertyName})"; + return new Expr\StaticCall( + new Name($type->type), + $enumFunction, + [ + new Arg($value), + ], + ); } if (\is_subclass_of($type->type, MapsItself::class)) { - return "{$type->type}::mapSelf({$propertyName}, \$mapper)"; + return new Expr\StaticCall( + new Name($type->type), + 'mapSelf', + [ + new Arg($value), + new Variable('mapper'), + ], + ); } $className = $this->dataTypeFactory->print($type, $bluePrint->fileName); if (\class_exists($className)) { - return "\$mapper->objectMapper->map('{$className}', {$propertyName})"; + return new Expr\MethodCall( + new Expr\PropertyFetch( + new Variable('mapper'), + 'objectMapper', + ), + 'map', + [ + new Arg(new String_($className)), + new Arg($value), + ], + ); } } - return '$mapper->map(\'' . $this->dataTypeFactory->print($type, $bluePrint->fileName) . '\', ' . $propertyName . ')'; + return new Expr\MethodCall( + new Variable('mapper'), + 'map', + [ + new Arg(new String_($this->dataTypeFactory->print($type, $bluePrint->fileName))), + new Arg($value), + ], + ); } - private function wrapDefault(string $value, string $arrayKey, mixed $defaultValue): string + private function wrapDefault(Expr $value, string $arrayKey, mixed $defaultValue): Expr { - if (\str_contains($value, '?')) { - $value = "({$value})"; - } - if (\is_object($defaultValue)) { - $defaultRaw = 'new ' . $defaultValue::class . '()'; + $defaultRaw = new New_(new Name($defaultValue::class)); } else { - $defaultRaw = \var_export($defaultValue, true); + $defaultRaw = new ConstFetch(new Name(\var_export($defaultValue, true))); } - return "(\\array_key_exists('{$arrayKey}', \$data) ? {$value} : {$defaultRaw})"; + return new Ternary( + new FuncCall( + new Name\FullyQualified('array_key_exists'), + [ + new Arg(new String_($arrayKey)), + new Arg(new Variable('data')), + ], + ), + $value, + $defaultRaw, + ); } - private function wrapArrayKeyExists(string $expression, string $arrayKey): string + /** @param Node|array $expression */ + private function wrapArrayKeyExists(Node|array $expression, string $arrayKey): Stmt\If_ { - $content = \PHP_EOL . \str_repeat(' ', 2) . "if (\\array_key_exists('{$arrayKey}', \$data)) {"; - $content .= \str_replace(\PHP_EOL, \PHP_EOL . ' ', $expression) . \PHP_EOL; - $content .= \str_repeat(' ', 2) . '}'; - - return $content; + return new Stmt\If_( + new FuncCall( + new Name\FullyQualified('array_key_exists'), + [ + new Arg(new String_($arrayKey)), + new Arg(new Variable('data')), + ], + ), + [ + 'stmts' => \is_array($expression) + ? $expression + : [ + $expression instanceof Expr ? new Stmt\Expression($expression) : $expression, + ], + ], + ); } public function __destruct() @@ -256,19 +433,68 @@ public function __destruct() } } - /** @param array{type: DataTypeCollection, default?: mixed} $property */ - private function buildPropertyForeachMapping(string $propertyName, array $property, ClassBluePrint $blueprint): string + /** + * @param array{type: DataTypeCollection, default?: mixed} $property + * @return array + */ + private function buildPropertyForeachMapping(string $propertyName, array $property, ClassBluePrint $blueprint): array { - $foreach = \PHP_EOL . \str_repeat(' ', 2) . '$x->' . $propertyName . ' = [];'; - $foreach .= \PHP_EOL . \str_repeat(' ', 2) . 'foreach ($data[\'' . $propertyName . '\'] as $key => $value) {'; - $foreach .= \PHP_EOL . \str_repeat(' ', 3) . '$x->' . $propertyName . '[' . $this->castInMapperFunction('$key', $property['type']->types[0]->genericTypes[0], $blueprint) . '] = '; - $foreach .= $this->castInMapperFunction('$value', $property['type']->types[0]->genericTypes[1], $blueprint) . ';'; - $foreach .= \PHP_EOL . \str_repeat(' ', 2) . '}'; + $ast = []; + $ast[] = new Stmt\Expression( + new Assign( + new PropertyFetch( + new Variable('x'), + $propertyName, + ), + new Array_(attributes: ['kind' => Array_::KIND_SHORT]), + ), + ); + + $ast[] = new Stmt\Foreach_( + new ArrayDimFetch( + new Variable('data'), + new String_($propertyName), + ), + new Variable('value'), + [ + 'keyVar' => new Variable('key'), + 'stmts' => [ + new Stmt\Expression( + new Assign( + new ArrayDimFetch( + new PropertyFetch( + new Variable('x'), + $propertyName, + ), + $this->castInMapperFunction(new Variable('key'), $property['type']->types[0]->genericTypes[0], $blueprint), + ), + $this->castInMapperFunction(new Variable('value'), $property['type']->types[0]->genericTypes[1], $blueprint), + ), + ), + ], + ], + ); if (\array_key_exists('default', $property) || $this->mapper->config->allowUninitializedFields) { - $foreach = $this->wrapArrayKeyExists($foreach, $propertyName); + $if = $this->wrapArrayKeyExists($ast, $propertyName); + + if (\array_key_exists('default', $property)) { + $if->else = new Stmt\Else_([ + new Stmt\Expression( + new Assign( + new PropertyFetch( + new Variable('x'), + $propertyName, + ), + new ConstFetch(new Name(\var_export($property['default'], true))), + ), + ), + ]); + } + + $ast = [$if]; } - return $foreach; + return $ast; } } diff --git a/tests/MapperTest.php b/tests/MapperTest.php index c29ffe4..7ee7e5f 100644 --- a/tests/MapperTest.php +++ b/tests/MapperTest.php @@ -220,6 +220,10 @@ public static function objectValuesDataProvider(): Generator 'bar' => 8, ], ]; + $dto->keyValue = [ + 'foo' => 5, + 'bar' => 8, + ]; yield [ ArrayProperties::class, (array)$dto, diff --git a/tests/_Mocks/ArrayProperties.php b/tests/_Mocks/ArrayProperties.php index a488ed5..59a8f4c 100644 --- a/tests/_Mocks/ArrayProperties.php +++ b/tests/_Mocks/ArrayProperties.php @@ -9,4 +9,7 @@ class ArrayProperties /** @var array */ public array $fieldsWithKeys; + + /** @var array */ + public array $keyValue; }