Skip to content

Commit

Permalink
add Optional attribute that allows specifying default value
Browse files Browse the repository at this point in the history
  • Loading branch information
JanTvrdik committed May 31, 2024
1 parent 6e762d4 commit 2a1d022
Show file tree
Hide file tree
Showing 10 changed files with 376 additions and 1 deletion.
17 changes: 17 additions & 0 deletions src/Compiler/Mapper/Optional.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php declare(strict_types = 1);

namespace ShipMonk\InputMapper\Compiler\Mapper;

use Attribute;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
class Optional
{

public function __construct(
public readonly mixed $default = null,
)
{
}

}
93 changes: 93 additions & 0 deletions src/Compiler/Mapper/Wrapper/MapDefaultValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php declare(strict_types = 1);

namespace ShipMonk\InputMapper\Compiler\Mapper\Wrapper;

use Attribute;
use BackedEnum;
use LogicException;
use PhpParser\Node\Expr;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use ShipMonk\InputMapper\Compiler\CompiledExpr;
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils;
use function array_is_list;
use function array_keys;
use function array_map;
use function array_values;
use function get_debug_type;
use function is_array;
use function is_scalar;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
class MapDefaultValue implements UndefinedAwareMapperCompiler
{

public function __construct(
public readonly MapperCompiler $mapperCompiler,
public readonly mixed $defaultValue,
)
{
}

public function compile(Expr $value, Expr $path, PhpCodeBuilder $builder): CompiledExpr
{
return $this->mapperCompiler->compile($value, $path, $builder);
}

public function compileUndefined(Expr $path, Expr $key, PhpCodeBuilder $builder): CompiledExpr
{
if ($this->defaultValue === null || is_scalar($this->defaultValue) || is_array($this->defaultValue)) {
return new CompiledExpr($builder->val(null));
}

if ($this->defaultValue instanceof BackedEnum) {
return new CompiledExpr($builder->classConstFetch($builder->importClass($this->defaultValue::class), $this->defaultValue->name));
}

throw new LogicException('Unsupported default value type: ' . get_debug_type($this->defaultValue));
}

public function getInputType(): TypeNode
{
return $this->mapperCompiler->getInputType();
}

public function getOutputType(): TypeNode
{
return $this->mapperCompiler->getOutputType();
}

public function getDefaultValueType(): TypeNode
{
return $this->typeFromValue($this->defaultValue);
}

private function typeFromValue(mixed $value): TypeNode
{
if (is_scalar($value) || $value === null) {
return new IdentifierTypeNode(get_debug_type($value));
}

if (is_array($value)) {
if (array_is_list($value)) {
$valueType = PhpDocTypeUtils::union(...array_map($this->typeFromValue(...), $value));
return new GenericTypeNode(new IdentifierTypeNode('list'), [$valueType]);
}

$keyType = PhpDocTypeUtils::union(...array_map($this->typeFromValue(...), array_keys($value)));
$valueType = PhpDocTypeUtils::union(...array_map($this->typeFromValue(...), array_values($value)));
return new GenericTypeNode(new IdentifierTypeNode('array'), [$keyType, $valueType]);
}

if ($value instanceof BackedEnum) {
return new IdentifierTypeNode($value::class);
}

throw new LogicException('Unsupported default value type: ' . get_debug_type($value));
}

}
14 changes: 13 additions & 1 deletion src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDateTimeImmutable;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapEnum;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapObject;
use ShipMonk\InputMapper\Compiler\Mapper\Optional as OptionalAttribute;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapBool;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapFloat;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapInt;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapString;
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ChainMapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapDefaultValue;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapNullable;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapOptional;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler;
Expand Down Expand Up @@ -74,6 +77,7 @@ class DefaultMapperCompilerFactory implements MapperCompilerFactory

final public const DELEGATE_OBJECT_MAPPING = 'delegateObjectMapping';
final public const GENERIC_PARAMETERS = 'genericParameters';
final public const DEFAULT_VALUE = 'defaultValue';

/**
* @param array<class-string, callable(class-string, array<string, mixed>): MapperCompiler> $mapperCompilerFactories
Expand Down Expand Up @@ -213,7 +217,7 @@ public function create(TypeNode $type, array $options = []): MapperCompiler
}

if ($isNullable && count($subTypesWithoutNull) === 1) {
return new MapNullable($this->createInner($subTypesWithoutNull[0], $options));
return $this->create(new NullableTypeNode($subTypesWithoutNull[0]), $options);
}
}

Expand Down Expand Up @@ -396,6 +400,10 @@ protected function createParameterMapperCompiler(
default => new ChainMapperCompiler($mappers),
};

foreach ($parameterReflection->getAttributes(OptionalAttribute::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$mapper = new MapDefaultValue($mapper, $attribute->newInstance()->default);
}

if (!PhpDocTypeUtils::isSubTypeOf($mapper->getOutputType(), $type)) {
throw CannotCreateMapperCompilerException::withIncompatibleMapperForMethodParameter($mapper, $parameterReflection, $type);
}
Expand Down Expand Up @@ -425,6 +433,10 @@ protected function addValidator(
return new ValidatedMapperCompiler($mapperCompiler, [$validatorCompiler]);
}

if ($mapperCompiler instanceof MapDefaultValue) {
return new MapDefaultValue($this->addValidator($mapperCompiler->mapperCompiler, $validatorCompiler), $mapperCompiler->defaultValue);
}

if ($mapperCompiler instanceof MapNullable) {
return new MapNullable($this->addValidator($mapperCompiler->innerMapperCompiler, $validatorCompiler));
}
Expand Down
34 changes: 34 additions & 0 deletions tests/Compiler/Mapper/Wrapper/Data/IntWithDefaultValueMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare (strict_types=1);

namespace ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper\Data;

use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapDefaultValue;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use function is_int;

/**
* Generated mapper by {@see MapDefaultValue}. Do not edit directly.
*
* @implements Mapper<int>
*/
class IntWithDefaultValueMapper implements Mapper
{
public function __construct(private readonly MapperProvider $provider)
{
}

/**
* @param list<string|int> $path
* @throws MappingFailedException
*/
public function map(mixed $data, array $path = []): int
{
if (!is_int($data)) {
throw MappingFailedException::incorrectType($data, $path, 'int');
}

return $data;
}
}
20 changes: 20 additions & 0 deletions tests/Compiler/Mapper/Wrapper/Data/Semaphore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php declare (strict_types = 1);

namespace ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper\Data;

use ShipMonk\InputMapper\Compiler\Mapper\Optional;

class Semaphore
{

public function __construct(
#[Optional(default: SemaphoreColorEnum::Red)]
public readonly SemaphoreColorEnum $color,

#[Optional]
public readonly ?string $manufacturer,
)
{
}

}
12 changes: 12 additions & 0 deletions tests/Compiler/Mapper/Wrapper/Data/SemaphoreColorEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php declare (strict_types = 1);

namespace ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper\Data;

enum SemaphoreColorEnum: string
{

case Red = 'red';
case Yellow = 'yellow';
case Green = 'green';

}
89 changes: 89 additions & 0 deletions tests/Compiler/Mapper/Wrapper/Data/SemaphoreMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php declare (strict_types=1);

namespace ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper\Data;

use ShipMonk\InputMapper\Compiler\Mapper\Object\MapObject;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use function array_column;
use function array_diff_key;
use function array_key_exists;
use function array_keys;
use function count;
use function implode;
use function is_array;
use function is_string;

/**
* Generated mapper by {@see MapObject}. Do not edit directly.
*
* @implements Mapper<Semaphore>
*/
class SemaphoreMapper implements Mapper
{
public function __construct(private readonly MapperProvider $provider)
{
}

/**
* @param list<string|int> $path
* @throws MappingFailedException
*/
public function map(mixed $data, array $path = []): Semaphore
{
if (!is_array($data)) {
throw MappingFailedException::incorrectType($data, $path, 'array');
}

$knownKeys = ['color' => true, 'manufacturer' => true];
$extraKeys = array_diff_key($data, $knownKeys);

if (count($extraKeys) > 0) {
throw MappingFailedException::extraKeys($path, array_keys($extraKeys));
}

return new Semaphore(
array_key_exists('color', $data) ? $this->mapColor($data['color'], [...$path, 'color']) : SemaphoreColorEnum::Green,
array_key_exists('manufacturer', $data) ? $this->mapManufacturer($data['manufacturer'], [...$path, 'manufacturer']) : null,
);
}

/**
* @param list<string|int> $path
* @throws MappingFailedException
*/
private function mapColor(mixed $data, array $path = []): SemaphoreColorEnum
{
if (!is_string($data)) {
throw MappingFailedException::incorrectType($data, $path, 'string');
}

$enum = SemaphoreColorEnum::tryFrom($data);

if ($enum === null) {
throw MappingFailedException::incorrectValue($data, $path, 'one of ' . implode(', ', array_column(SemaphoreColorEnum::cases(), 'value')));
}

return $enum;
}

/**
* @param list<string|int> $path
* @throws MappingFailedException
*/
private function mapManufacturer(mixed $data, array $path = []): ?string
{
if ($data === null) {
$mapped = null;
} else {
if (!is_string($data)) {
throw MappingFailedException::incorrectType($data, $path, 'string');
}

$mapped = $data;
}

return $mapped;
}
}
48 changes: 48 additions & 0 deletions tests/Compiler/Mapper/Wrapper/MapDefaultValueTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);

namespace ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper;

use ShipMonk\InputMapper\Compiler\Mapper\Object\MapEnum;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapObject;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapInt;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapString;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapDefaultValue;
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapNullable;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use ShipMonkTests\InputMapper\Compiler\Mapper\MapperCompilerTestCase;
use ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper\Data\Semaphore;
use ShipMonkTests\InputMapper\Compiler\Mapper\Wrapper\Data\SemaphoreColorEnum;

class MapDefaultValueTest extends MapperCompilerTestCase
{

public function testCompile(): void
{
$mapperCompiler = new MapDefaultValue(new MapInt(), null);
$mapper = $this->compileMapper('IntWithDefaultValue', $mapperCompiler);

self::assertSame(1, $mapper->map(1));
self::assertSame(2, $mapper->map(2));

self::assertException(
MappingFailedException::class,
'Failed to map data at path /: Expected int, got "1"',
static fn() => $mapper->map('1'),
);
}

public function testCompileUndefined(): void
{
$mapperCompiler = new MapObject(Semaphore::class, [
'color' => new MapDefaultValue(new MapEnum(SemaphoreColorEnum::class, new MapString()), SemaphoreColorEnum::Green),
'manufacturer' => new MapDefaultValue(new MapNullable(new MapString()), null),
]);

$mapper = $this->compileMapper('Semaphore', $mapperCompiler);

self::assertEquals(new Semaphore(SemaphoreColorEnum::Green, null), $mapper->map([]));
self::assertEquals(new Semaphore(SemaphoreColorEnum::Red, null), $mapper->map(['color' => 'red']));
self::assertEquals(new Semaphore(SemaphoreColorEnum::Red, 'Siemens'), $mapper->map(['color' => 'red', 'manufacturer' => 'Siemens']));
}

}
Loading

0 comments on commit 2a1d022

Please sign in to comment.