Skip to content

Commit

Permalink
add Optional attribute that allows specifying default value (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
JanTvrdik authored Jun 6, 2024
1 parent 3df4773 commit cc5fb15
Show file tree
Hide file tree
Showing 21 changed files with 476 additions and 19 deletions.
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ You can write your own validators if you need more.

To use Input Mapper, write a class with a public constructor and add either native or PHPDoc types to all constructor parameters.

Optional fields need to be wrapped with the Optional class, which allows distinguishing between null and missing values.
Optional fields can either be marked with `#[Optional]` attribute (allowing you to specify a default value),
or if you need to distinguish between default and missing values, you can wrap the type with `ShipMonk\InputMapper\Runtime\Optional` class.

```php
use ShipMonk\InputMapper\Runtime\Optional;
use ShipMonk\InputMapper\Compiler\Mapper\Optional;

class Person
{
Expand All @@ -78,14 +79,15 @@ class Person

public readonly int $age,

/** @var Optional<string> */
public readonly Optional $email,
#[Optional]
public readonly ?string $email,

/** @var list<string> */
public readonly array $hobbies,

/** @var Optional<list<self>> */
public readonly Optional $friends,
/** @var list<self> */
#[Optional(default: [])]
public readonly array $friends,
) {}
}
```
Expand Down
20 changes: 20 additions & 0 deletions src/Compiler/Exception/CannotCreateMapperCompilerException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use ReflectionParameter;
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
use Throwable;

Expand Down Expand Up @@ -37,6 +38,25 @@ public static function withIncompatibleMapperForMethodParameter(
return new self("Cannot use mapper {$mapperCompilerClass} for parameter \${$parameterName} of method {$methodFullName}, because {$reason}", 0, $previous);
}

public static function withIncompatibleDefaultValueParameter(
UndefinedAwareMapperCompiler $mapperCompiler,
ReflectionParameter $parameter,
TypeNode $parameterType,
?Throwable $previous = null
): self
{
$mapperCompilerClass = $mapperCompiler::class;
$defaultValueType = $mapperCompiler->getDefaultValueType();

$parameterName = $parameter->getName();
$className = $parameter->getDeclaringClass()?->getName();
$methodName = $parameter->getDeclaringFunction()->getName();
$methodFullName = $className !== null ? "{$className}::{$methodName}" : $methodName;

$reason = "default value of type '{$defaultValueType}' is not compatible with parameter type '{$parameterType}'";
return new self("Cannot use mapper {$mapperCompilerClass} for parameter \${$parameterName} of method {$methodFullName}, because {$reason}", 0, $previous);
}

public static function withIncompatibleValidator(
ValidatorCompiler $validatorCompiler,
MapperCompiler $mapperCompiler,
Expand Down
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,
)
{
}

}
3 changes: 3 additions & 0 deletions src/Compiler/Mapper/UndefinedAwareMapperCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ShipMonk\InputMapper\Compiler\Mapper;

use PhpParser\Node\Expr;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use ShipMonk\InputMapper\Compiler\CompiledExpr;
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;

Expand All @@ -11,4 +12,6 @@ interface UndefinedAwareMapperCompiler extends MapperCompiler

public function compileUndefined(Expr $path, Expr $key, PhpCodeBuilder $builder): CompiledExpr;

public function getDefaultValueType(): TypeNode;

}
63 changes: 63 additions & 0 deletions src/Compiler/Mapper/Wrapper/MapDefaultValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?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\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 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($this->defaultValue));
}

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 PhpDocTypeUtils::fromValue($this->defaultValue);
}

}
9 changes: 8 additions & 1 deletion src/Compiler/Mapper/Wrapper/MapOptional.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
use ShipMonk\InputMapper\Runtime\Optional;
use ShipMonk\InputMapper\Runtime\OptionalNone;
use ShipMonk\InputMapper\Runtime\OptionalSome;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
class MapOptional implements UndefinedAwareMapperCompiler
Expand Down Expand Up @@ -44,9 +46,14 @@ public function getInputType(): TypeNode
public function getOutputType(): TypeNode
{
return new GenericTypeNode(
new IdentifierTypeNode(Optional::class),
new IdentifierTypeNode(OptionalSome::class),
[$this->mapperCompiler->getOutputType()],
);
}

public function getDefaultValueType(): TypeNode
{
return new IdentifierTypeNode(OptionalNone::class);
}

}
20 changes: 19 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,10 +400,20 @@ 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);
}

if ($mapper instanceof UndefinedAwareMapperCompiler) {
if (!PhpDocTypeUtils::isSubTypeOf($mapper->getDefaultValueType(), $type)) {
throw CannotCreateMapperCompilerException::withIncompatibleDefaultValueParameter($mapper, $parameterReflection, $type);
}
}

foreach ($validators as $validator) {
$mapper = $this->addValidator($mapper, $validator);
}
Expand All @@ -419,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
42 changes: 42 additions & 0 deletions src/Compiler/Type/PhpDocTypeUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use ReflectionUnionType;
use Traversable;
use function array_flip;
use function array_is_list;
use function array_keys;
use function array_map;
use function array_shift;
Expand All @@ -54,6 +55,7 @@
use function class_exists;
use function constant;
use function count;
use function get_debug_type;
use function get_object_vars;
use function in_array;
use function interface_exists;
Expand All @@ -63,6 +65,8 @@
use function is_float;
use function is_int;
use function is_object;
use function is_resource;
use function is_scalar;
use function is_string;
use function max;
use function method_exists;
Expand Down Expand Up @@ -151,6 +155,44 @@ public static function fromReflectionType(ReflectionType $reflectionType): TypeN
return new IdentifierTypeNode('mixed');
}

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

if (is_array($value)) {
$items = [];
$isList = array_is_list($value);

foreach ($value as $k => $v) {
$keyName = match (true) {
$isList => null,
is_int($k) => new ConstExprIntegerNode((string) $k),
is_string($k) => new IdentifierTypeNode($k),
};

$items[] = new ArrayShapeItemNode(
keyName: $keyName,
optional: false,
valueType: self::fromValue($v),
);
}

return new ArrayShapeNode($items);
}

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

if (is_resource($value)) {
return new IdentifierTypeNode('resource');
}

throw new LogicException('Unsupported value type');
}

/**
* @param list<GenericTypeParameter> $genericParameters
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use ShipMonk\InputMapper\Runtime\Optional;
use ShipMonk\InputMapper\Runtime\OptionalSome;
use function array_diff_key;
use function array_key_exists;
use function array_keys;
Expand Down Expand Up @@ -86,10 +87,10 @@ private function mapName(mixed $data, array $path = []): string

/**
* @param list<string|int> $path
* @return Optional<int>
* @return OptionalSome<int>
* @throws MappingFailedException
*/
private function mapAge(mixed $data, array $path = []): Optional
private function mapAge(mixed $data, array $path = []): OptionalSome
{
if (!is_int($data)) {
throw MappingFailedException::incorrectType($data, $path, 'int');
Expand Down
5 changes: 3 additions & 2 deletions tests/Compiler/Mapper/Object/Data/MovieMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use ShipMonk\InputMapper\Runtime\Optional;
use ShipMonk\InputMapper\Runtime\OptionalSome;
use function array_diff_key;
use function array_is_list;
use function array_key_exists;
Expand Down Expand Up @@ -107,10 +108,10 @@ private function mapTitle(mixed $data, array $path = []): string

/**
* @param list<string|int> $path
* @return Optional<string>
* @return OptionalSome<string>
* @throws MappingFailedException
*/
private function mapDescription(mixed $data, array $path = []): Optional
private function mapDescription(mixed $data, array $path = []): OptionalSome
{
if (!is_string($data)) {
throw MappingFailedException::incorrectType($data, $path, 'string');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use ShipMonk\InputMapper\Runtime\Optional;
use ShipMonk\InputMapper\Runtime\OptionalSome;
use function array_diff_key;
use function array_key_exists;
use function array_keys;
Expand Down Expand Up @@ -86,10 +87,10 @@ private function mapName(mixed $data, array $path = []): string

/**
* @param list<string|int> $path
* @return Optional<int>
* @return OptionalSome<int>
* @throws MappingFailedException
*/
private function mapAge(mixed $data, array $path = []): Optional
private function mapAge(mixed $data, array $path = []): OptionalSome
{
if (!is_int($data)) {
throw MappingFailedException::incorrectType($data, $path, 'int');
Expand Down
Loading

0 comments on commit cc5fb15

Please sign in to comment.