Skip to content

Commit cc5fb15

Browse files
authored
add Optional attribute that allows specifying default value (#60)
1 parent 3df4773 commit cc5fb15

21 files changed

+476
-19
lines changed

README.md

+8-6
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ You can write your own validators if you need more.
6666

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

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

7172
```php
72-
use ShipMonk\InputMapper\Runtime\Optional;
73+
use ShipMonk\InputMapper\Compiler\Mapper\Optional;
7374

7475
class Person
7576
{
@@ -78,14 +79,15 @@ class Person
7879

7980
public readonly int $age,
8081

81-
/** @var Optional<string> */
82-
public readonly Optional $email,
82+
#[Optional]
83+
public readonly ?string $email,
8384

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

87-
/** @var Optional<list<self>> */
88-
public readonly Optional $friends,
88+
/** @var list<self> */
89+
#[Optional(default: [])]
90+
public readonly array $friends,
8991
) {}
9092
}
9193
```

src/Compiler/Exception/CannotCreateMapperCompilerException.php

+20
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
77
use ReflectionParameter;
88
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
9+
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
910
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
1011
use Throwable;
1112

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

41+
public static function withIncompatibleDefaultValueParameter(
42+
UndefinedAwareMapperCompiler $mapperCompiler,
43+
ReflectionParameter $parameter,
44+
TypeNode $parameterType,
45+
?Throwable $previous = null
46+
): self
47+
{
48+
$mapperCompilerClass = $mapperCompiler::class;
49+
$defaultValueType = $mapperCompiler->getDefaultValueType();
50+
51+
$parameterName = $parameter->getName();
52+
$className = $parameter->getDeclaringClass()?->getName();
53+
$methodName = $parameter->getDeclaringFunction()->getName();
54+
$methodFullName = $className !== null ? "{$className}::{$methodName}" : $methodName;
55+
56+
$reason = "default value of type '{$defaultValueType}' is not compatible with parameter type '{$parameterType}'";
57+
return new self("Cannot use mapper {$mapperCompilerClass} for parameter \${$parameterName} of method {$methodFullName}, because {$reason}", 0, $previous);
58+
}
59+
4060
public static function withIncompatibleValidator(
4161
ValidatorCompiler $validatorCompiler,
4262
MapperCompiler $mapperCompiler,

src/Compiler/Mapper/Optional.php

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\InputMapper\Compiler\Mapper;
4+
5+
use Attribute;
6+
7+
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
8+
class Optional
9+
{
10+
11+
public function __construct(
12+
public readonly mixed $default = null,
13+
)
14+
{
15+
}
16+
17+
}

src/Compiler/Mapper/UndefinedAwareMapperCompiler.php

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ShipMonk\InputMapper\Compiler\Mapper;
44

55
use PhpParser\Node\Expr;
6+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
67
use ShipMonk\InputMapper\Compiler\CompiledExpr;
78
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
89

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

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

15+
public function getDefaultValueType(): TypeNode;
16+
1417
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\InputMapper\Compiler\Mapper\Wrapper;
4+
5+
use Attribute;
6+
use BackedEnum;
7+
use LogicException;
8+
use PhpParser\Node\Expr;
9+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
10+
use ShipMonk\InputMapper\Compiler\CompiledExpr;
11+
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
12+
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
13+
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
14+
use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils;
15+
use function get_debug_type;
16+
use function is_array;
17+
use function is_scalar;
18+
19+
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
20+
class MapDefaultValue implements UndefinedAwareMapperCompiler
21+
{
22+
23+
public function __construct(
24+
public readonly MapperCompiler $mapperCompiler,
25+
public readonly mixed $defaultValue,
26+
)
27+
{
28+
}
29+
30+
public function compile(Expr $value, Expr $path, PhpCodeBuilder $builder): CompiledExpr
31+
{
32+
return $this->mapperCompiler->compile($value, $path, $builder);
33+
}
34+
35+
public function compileUndefined(Expr $path, Expr $key, PhpCodeBuilder $builder): CompiledExpr
36+
{
37+
if ($this->defaultValue === null || is_scalar($this->defaultValue) || is_array($this->defaultValue)) {
38+
return new CompiledExpr($builder->val($this->defaultValue));
39+
}
40+
41+
if ($this->defaultValue instanceof BackedEnum) {
42+
return new CompiledExpr($builder->classConstFetch($builder->importClass($this->defaultValue::class), $this->defaultValue->name));
43+
}
44+
45+
throw new LogicException('Unsupported default value type: ' . get_debug_type($this->defaultValue));
46+
}
47+
48+
public function getInputType(): TypeNode
49+
{
50+
return $this->mapperCompiler->getInputType();
51+
}
52+
53+
public function getOutputType(): TypeNode
54+
{
55+
return $this->mapperCompiler->getOutputType();
56+
}
57+
58+
public function getDefaultValueType(): TypeNode
59+
{
60+
return PhpDocTypeUtils::fromValue($this->defaultValue);
61+
}
62+
63+
}

src/Compiler/Mapper/Wrapper/MapOptional.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
1313
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
1414
use ShipMonk\InputMapper\Runtime\Optional;
15+
use ShipMonk\InputMapper\Runtime\OptionalNone;
16+
use ShipMonk\InputMapper\Runtime\OptionalSome;
1517

1618
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
1719
class MapOptional implements UndefinedAwareMapperCompiler
@@ -44,9 +46,14 @@ public function getInputType(): TypeNode
4446
public function getOutputType(): TypeNode
4547
{
4648
return new GenericTypeNode(
47-
new IdentifierTypeNode(Optional::class),
49+
new IdentifierTypeNode(OptionalSome::class),
4850
[$this->mapperCompiler->getOutputType()],
4951
);
5052
}
5153

54+
public function getDefaultValueType(): TypeNode
55+
{
56+
return new IdentifierTypeNode(OptionalNone::class);
57+
}
58+
5259
}

src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php

+19-1
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@
4040
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDateTimeImmutable;
4141
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapEnum;
4242
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapObject;
43+
use ShipMonk\InputMapper\Compiler\Mapper\Optional as OptionalAttribute;
4344
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapBool;
4445
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapFloat;
4546
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapInt;
4647
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapString;
48+
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
4749
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ChainMapperCompiler;
50+
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapDefaultValue;
4851
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapNullable;
4952
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapOptional;
5053
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler;
@@ -74,6 +77,7 @@ class DefaultMapperCompilerFactory implements MapperCompilerFactory
7477

7578
final public const DELEGATE_OBJECT_MAPPING = 'delegateObjectMapping';
7679
final public const GENERIC_PARAMETERS = 'genericParameters';
80+
final public const DEFAULT_VALUE = 'defaultValue';
7781

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

215219
if ($isNullable && count($subTypesWithoutNull) === 1) {
216-
return new MapNullable($this->createInner($subTypesWithoutNull[0], $options));
220+
return $this->create(new NullableTypeNode($subTypesWithoutNull[0]), $options);
217221
}
218222
}
219223

@@ -396,10 +400,20 @@ protected function createParameterMapperCompiler(
396400
default => new ChainMapperCompiler($mappers),
397401
};
398402

403+
foreach ($parameterReflection->getAttributes(OptionalAttribute::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
404+
$mapper = new MapDefaultValue($mapper, $attribute->newInstance()->default);
405+
}
406+
399407
if (!PhpDocTypeUtils::isSubTypeOf($mapper->getOutputType(), $type)) {
400408
throw CannotCreateMapperCompilerException::withIncompatibleMapperForMethodParameter($mapper, $parameterReflection, $type);
401409
}
402410

411+
if ($mapper instanceof UndefinedAwareMapperCompiler) {
412+
if (!PhpDocTypeUtils::isSubTypeOf($mapper->getDefaultValueType(), $type)) {
413+
throw CannotCreateMapperCompilerException::withIncompatibleDefaultValueParameter($mapper, $parameterReflection, $type);
414+
}
415+
}
416+
403417
foreach ($validators as $validator) {
404418
$mapper = $this->addValidator($mapper, $validator);
405419
}
@@ -419,6 +433,10 @@ protected function addValidator(
419433
return new ValidatedMapperCompiler($mapperCompiler, [$validatorCompiler]);
420434
}
421435

436+
if ($mapperCompiler instanceof MapDefaultValue) {
437+
return new MapDefaultValue($this->addValidator($mapperCompiler->mapperCompiler, $validatorCompiler), $mapperCompiler->defaultValue);
438+
}
439+
422440
if ($mapperCompiler instanceof MapNullable) {
423441
return new MapNullable($this->addValidator($mapperCompiler->innerMapperCompiler, $validatorCompiler));
424442
}

src/Compiler/Type/PhpDocTypeUtils.php

+42
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
use ReflectionUnionType;
4747
use Traversable;
4848
use function array_flip;
49+
use function array_is_list;
4950
use function array_keys;
5051
use function array_map;
5152
use function array_shift;
@@ -54,6 +55,7 @@
5455
use function class_exists;
5556
use function constant;
5657
use function count;
58+
use function get_debug_type;
5759
use function get_object_vars;
5860
use function in_array;
5961
use function interface_exists;
@@ -63,6 +65,8 @@
6365
use function is_float;
6466
use function is_int;
6567
use function is_object;
68+
use function is_resource;
69+
use function is_scalar;
6670
use function is_string;
6771
use function max;
6872
use function method_exists;
@@ -151,6 +155,44 @@ public static function fromReflectionType(ReflectionType $reflectionType): TypeN
151155
return new IdentifierTypeNode('mixed');
152156
}
153157

158+
public static function fromValue(mixed $value): TypeNode
159+
{
160+
if (is_scalar($value) || $value === null) {
161+
return new IdentifierTypeNode(get_debug_type($value));
162+
}
163+
164+
if (is_array($value)) {
165+
$items = [];
166+
$isList = array_is_list($value);
167+
168+
foreach ($value as $k => $v) {
169+
$keyName = match (true) {
170+
$isList => null,
171+
is_int($k) => new ConstExprIntegerNode((string) $k),
172+
is_string($k) => new IdentifierTypeNode($k),
173+
};
174+
175+
$items[] = new ArrayShapeItemNode(
176+
keyName: $keyName,
177+
optional: false,
178+
valueType: self::fromValue($v),
179+
);
180+
}
181+
182+
return new ArrayShapeNode($items);
183+
}
184+
185+
if (is_object($value)) {
186+
return new IdentifierTypeNode($value::class);
187+
}
188+
189+
if (is_resource($value)) {
190+
return new IdentifierTypeNode('resource');
191+
}
192+
193+
throw new LogicException('Unsupported value type');
194+
}
195+
154196
/**
155197
* @param list<GenericTypeParameter> $genericParameters
156198
*/

tests/Compiler/Mapper/Object/Data/DelegateToPerson__PersonInputMapper.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use ShipMonk\InputMapper\Runtime\Mapper;
88
use ShipMonk\InputMapper\Runtime\MapperProvider;
99
use ShipMonk\InputMapper\Runtime\Optional;
10+
use ShipMonk\InputMapper\Runtime\OptionalSome;
1011
use function array_diff_key;
1112
use function array_key_exists;
1213
use function array_keys;
@@ -86,10 +87,10 @@ private function mapName(mixed $data, array $path = []): string
8687

8788
/**
8889
* @param list<string|int> $path
89-
* @return Optional<int>
90+
* @return OptionalSome<int>
9091
* @throws MappingFailedException
9192
*/
92-
private function mapAge(mixed $data, array $path = []): Optional
93+
private function mapAge(mixed $data, array $path = []): OptionalSome
9394
{
9495
if (!is_int($data)) {
9596
throw MappingFailedException::incorrectType($data, $path, 'int');

tests/Compiler/Mapper/Object/Data/MovieMapper.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use ShipMonk\InputMapper\Runtime\Mapper;
88
use ShipMonk\InputMapper\Runtime\MapperProvider;
99
use ShipMonk\InputMapper\Runtime\Optional;
10+
use ShipMonk\InputMapper\Runtime\OptionalSome;
1011
use function array_diff_key;
1112
use function array_is_list;
1213
use function array_key_exists;
@@ -107,10 +108,10 @@ private function mapTitle(mixed $data, array $path = []): string
107108

108109
/**
109110
* @param list<string|int> $path
110-
* @return Optional<string>
111+
* @return OptionalSome<string>
111112
* @throws MappingFailedException
112113
*/
113-
private function mapDescription(mixed $data, array $path = []): Optional
114+
private function mapDescription(mixed $data, array $path = []): OptionalSome
114115
{
115116
if (!is_string($data)) {
116117
throw MappingFailedException::incorrectType($data, $path, 'string');

tests/Compiler/Mapper/Object/Data/Movie__PersonInputMapper.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use ShipMonk\InputMapper\Runtime\Mapper;
88
use ShipMonk\InputMapper\Runtime\MapperProvider;
99
use ShipMonk\InputMapper\Runtime\Optional;
10+
use ShipMonk\InputMapper\Runtime\OptionalSome;
1011
use function array_diff_key;
1112
use function array_key_exists;
1213
use function array_keys;
@@ -86,10 +87,10 @@ private function mapName(mixed $data, array $path = []): string
8687

8788
/**
8889
* @param list<string|int> $path
89-
* @return Optional<int>
90+
* @return OptionalSome<int>
9091
* @throws MappingFailedException
9192
*/
92-
private function mapAge(mixed $data, array $path = []): Optional
93+
private function mapAge(mixed $data, array $path = []): OptionalSome
9394
{
9495
if (!is_int($data)) {
9596
throw MappingFailedException::incorrectType($data, $path, 'int');

0 commit comments

Comments
 (0)