Skip to content

Commit fd8cf34

Browse files
committed
Added default to attribute to specify default values
1 parent 04ea37a commit fd8cf34

File tree

12 files changed

+276
-4
lines changed

12 files changed

+276
-4
lines changed

docs/PropertyAttributes.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Property Attributes
44
In order to provide more functionality to your DTOs, you can use the following attributes:
55
- [Computed](#Computed) - To define a property that is computed from other properties.
66
- [Aliases](#Aliases) - To define aliases for a property.
7+
- [DefaultsUsing](#DefaultsUsing) - To define a default value for a property using a fallback resolver.
78
- [CipherTarget](#CipherTarget) - To define a property that should be encrypted/decrypted.
89

910
Computed
@@ -109,3 +110,48 @@ public function __construct(
109110
) {}
110111
```
111112

113+
DefaultsUsing
114+
-
115+
116+
Sometimes, we may need to specify that a property has a default value,
117+
we can achieve that using plain PHP for some property types but not all of them.
118+
119+
```php
120+
use Nuxtifyts\PhpDto\Data;
121+
122+
final readonly class User extends Data
123+
{
124+
public function __construct(
125+
public string $firstName,
126+
public string $lastName,
127+
public string $email,
128+
public UserType $type = UserType::DEFAULT,
129+
public UserConfigData $config,
130+
) {}
131+
}
132+
```
133+
134+
On the other hand, if we want to specify, for example, a default value for UserType depending
135+
on the provided email address, or if you want to provide a default value for complex data such as
136+
`UserConfigData` which is another DTO, there is no way to do it using plain PHP,
137+
that's where `DefaultsUsing` attribute comes in.
138+
139+
```php
140+
use Nuxtifyts\PhpDto\Data;
141+
use Nuxtifyts\PhpDto\Attributes\Property\DefaultsUsing;
142+
143+
final readonly class User extends Data
144+
{
145+
public function __construct(
146+
public string $firstName,
147+
public string $lastName,
148+
public string $email,
149+
#[DefaultsUsing(UserTypeFallbackResolver::class)]
150+
public UserType $type,
151+
#[DefaultsUsing(UserConfigDataFallbackResolver::class)]
152+
public UserConfigData $config,
153+
) {}
154+
}
155+
```
156+
157+
TODO - Add example of fall back resolver code
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Attributes\Property;
4+
5+
use Attribute;
6+
use BackedEnum;
7+
use Nuxtifyts\PhpDto\Exceptions\FallbackResolverException;
8+
use Nuxtifyts\PhpDto\FallbackResolver\FallbackResolver;
9+
use ReflectionClass;
10+
11+
#[Attribute(Attribute::TARGET_PROPERTY)]
12+
class DefaultsTo
13+
{
14+
/** @var array<string, ReflectionClass<object>> */
15+
protected static array $_resolverReflections = [];
16+
17+
/** @var ?class-string<FallbackResolver> */
18+
protected(set) ?string $fallbackResolverClass = null;
19+
20+
/**
21+
* @throws FallbackResolverException
22+
*/
23+
public function __construct(
24+
protected(set) BackedEnum|int|string|float|bool|null $value
25+
) {
26+
if (is_string($value) && class_exists($value)) {
27+
/** @var ReflectionClass<object> $reflection */
28+
$reflection = self::$_resolverReflections[$value] ??= new ReflectionClass($value);
29+
30+
if (!$reflection->implementsInterface(FallbackResolver::class)) {
31+
throw FallbackResolverException::unableToFindResolverClass($value);
32+
} else {
33+
/** @var class-string<FallbackResolver> $value */
34+
$this->fallbackResolverClass = $value;
35+
}
36+
}
37+
}
38+
}

src/Concerns/BaseData.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DecipherDataPipe;
99
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable;
1010
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\RefineDataPipe;
11+
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveDefaultDataPipe;
1112
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\ResolveValuesFromAliasesPipe;
1213
use Nuxtifyts\PhpDto\Support\Pipeline;
1314
use Nuxtifyts\PhpDto\Support\Traits\HasNormalizers;
@@ -26,7 +27,7 @@ final public static function from(mixed $value): static
2627
try {
2728
$value = static::normalizeValue($value, static::class);
2829

29-
if (empty($value)) {
30+
if ($value === false) {
3031
throw new DeserializeException(
3132
code: DeserializeException::INVALID_VALUE_ERROR_CODE
3233
);
@@ -39,6 +40,7 @@ final public static function from(mixed $value): static
3940
->through(ResolveValuesFromAliasesPipe::class)
4041
->through(RefineDataPipe::class)
4142
->through(DecipherDataPipe::class)
43+
->through(ResolveDefaultDataPipe::class)
4244
->sendThenReturn(new DeserializePipelinePassable(
4345
classContext: $context,
4446
data: $value

src/Contexts/PropertyContext.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Nuxtifyts\PhpDto\Attributes\Property\Aliases;
66
use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget;
77
use Nuxtifyts\PhpDto\Attributes\Property\Computed;
8+
use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo;
89
use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner;
910
use Nuxtifyts\PhpDto\DataCiphers\CipherConfig;
1011
use Nuxtifyts\PhpDto\DataRefiners\DataRefiner;
@@ -13,6 +14,7 @@
1314
use Nuxtifyts\PhpDto\Exceptions\SerializeException;
1415
use Nuxtifyts\PhpDto\Exceptions\UnknownTypeException;
1516
use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException;
17+
use Nuxtifyts\PhpDto\FallbackResolver\FallbackConfig;
1618
use Nuxtifyts\PhpDto\Serializers\Serializer;
1719
use Nuxtifyts\PhpDto\Support\Traits\HasSerializers;
1820
use Nuxtifyts\PhpDto\Support\Traits\HasTypes;
@@ -42,6 +44,8 @@ class PropertyContext
4244

4345
private(set) ?CipherConfig $cipherConfig = null;
4446

47+
private(set) ?FallbackConfig $fallbackConfig = null;
48+
4549
/** @var list<DataRefiner> */
4650
private(set) array $dataRefiners = [];
4751

@@ -112,6 +116,16 @@ private function syncPropertyAttributes(): void
112116
encoded: $instance->encoded
113117
);
114118
}
119+
120+
if ($defaultsToAttribute = $this->reflection->getAttributes(DefaultsTo::class)[0] ?? null) {
121+
/** @var ReflectionAttribute<DefaultsTo> $defaultsToAttribute */
122+
$instance = $defaultsToAttribute->newInstance();
123+
124+
$this->fallbackConfig = new FallbackConfig(
125+
value: $instance->value,
126+
resolverClass: $instance->fallbackResolverClass
127+
);
128+
}
115129
}
116130

117131
public function getValue(object $object): mixed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Exceptions;
4+
5+
use Exception;
6+
7+
class FallbackResolverException extends Exception
8+
{
9+
protected const int UNABLE_TO_FIND_RESOLVER_CLASS = 0;
10+
protected const int UNABLE_TO_RESOLVE_DEFAULT_VALUE = 1;
11+
12+
public static function unableToFindResolverClass(string $resolverClass): self
13+
{
14+
return new self(
15+
"Unable to find resolver class: {$resolverClass}",
16+
self::UNABLE_TO_FIND_RESOLVER_CLASS
17+
);
18+
}
19+
20+
public static function unableToResolveDefaultValue(): self
21+
{
22+
return new self(
23+
'Unable to resolve default value',
24+
self::UNABLE_TO_RESOLVE_DEFAULT_VALUE
25+
);
26+
}
27+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\FallbackResolver;
4+
5+
use BackedEnum;
6+
7+
readonly class FallbackConfig
8+
{
9+
/**
10+
* @param ?class-string<FallbackResolver> $resolverClass
11+
*/
12+
public function __construct(
13+
public BackedEnum|int|string|float|bool|null $value,
14+
public ?string $resolverClass = null
15+
) {
16+
}
17+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\FallbackResolver;
4+
5+
use Nuxtifyts\PhpDto\Contexts\PropertyContext;
6+
use Nuxtifyts\PhpDto\Exceptions\FallbackResolverException;
7+
8+
interface FallbackResolver
9+
{
10+
/**
11+
* @param array<string, mixed> $rawData
12+
*
13+
* @throws FallbackResolverException
14+
*/
15+
public static function resolve(array $rawData, PropertyContext $property): mixed;
16+
}

src/Normalizers/ArrayNormalizer.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ public function normalize(): array|false
88
{
99
if (
1010
!is_array($this->value)
11-
|| array_is_list($this->value)
11+
|| (
12+
array_is_list($this->value)
13+
&& !empty($this->value)
14+
)
1215
) {
1316
return false;
1417
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Pipelines\DeserializePipeline;
4+
5+
use Nuxtifyts\PhpDto\Support\Passable;
6+
use Nuxtifyts\PhpDto\Support\Pipe;
7+
8+
/**
9+
* @extends Pipe<DeserializePipelinePassable>
10+
*/
11+
readonly class ResolveDefaultDataPipe extends Pipe
12+
{
13+
public function handle(Passable $passable): DeserializePipelinePassable
14+
{
15+
$data = $passable->data;
16+
17+
foreach ($passable->classContext->properties as $propertyContext) {
18+
if (array_key_exists($propertyContext->propertyName, $data)) {
19+
continue;
20+
}
21+
22+
if ($propertyContext->fallbackConfig) {
23+
$data[$propertyContext->propertyName] = $propertyContext->fallbackConfig->resolverClass
24+
? $propertyContext->fallbackConfig->resolverClass::resolve($data, $propertyContext)
25+
: $propertyContext->fallbackConfig->value;
26+
}
27+
28+
if ($propertyContext->reflection->hasDefaultValue()) {
29+
$data[$propertyContext->propertyName] = $propertyContext->reflection->getDefaultValue();
30+
}
31+
}
32+
33+
return $passable->with(data: $data);
34+
}
35+
}

src/Support/Traits/HasNormalizers.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ protected static function normalizeValue(mixed $value, string $class): array|fal
2121
foreach (static::allNormalizer() as $normalizer) {
2222
$normalized = new $normalizer($value, $class)->normalize();
2323

24-
if (!empty($normalized)) {
24+
if ($normalized !== false) {
2525
return $normalized;
2626
}
2727
}

tests/Unit/Attributes/AliasesAttributeTest.php renamed to tests/Unit/Attributes/AliasesTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
#[CoversClass(ResolveValuesFromAliasesPipe::class)]
1717
#[CoversClass(Aliases::class)]
1818
#[UsesClass(PersonData::class)]
19-
final class AliasesAttributeTest extends UnitCase
19+
final class AliasesTest extends UnitCase
2020
{
2121
/**
2222
* @throws Throwable
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Tests\Unit\Attributes;
4+
5+
use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo;
6+
use Nuxtifyts\PhpDto\Contexts\PropertyContext;
7+
use Nuxtifyts\PhpDto\Data;
8+
use Nuxtifyts\PhpDto\Exceptions\FallbackResolverException;
9+
use Nuxtifyts\PhpDto\FallbackResolver\FallbackConfig;
10+
use Nuxtifyts\PhpDto\Tests\Dummies\UserData;
11+
use Nuxtifyts\PhpDto\Tests\Unit\UnitCase;
12+
use PHPUnit\Framework\Attributes\CoversClass;
13+
use PHPUnit\Framework\Attributes\DataProvider;
14+
use PHPUnit\Framework\Attributes\Test;
15+
use Throwable;
16+
17+
#[CoversClass(DefaultsTo::class)]
18+
#[CoversClass(FallbackResolverException::class)]
19+
#[CoversClass(FallbackConfig::class)]
20+
#[CoversClass(PropertyContext::class)]
21+
final class DefaultsToTest extends UnitCase
22+
{
23+
/**
24+
* @throws Throwable
25+
*/
26+
#[Test]
27+
public function should_throw_an_exception_if_the_resolver_class_does_not_implement_fallback_resolver_interface(): void
28+
{
29+
self::expectException(FallbackResolverException::class);
30+
31+
new DefaultsTo(UserData::class);
32+
}
33+
34+
/**
35+
* @param array<string, mixed> $arrayData
36+
* @param array<string, mixed> $expectedSerializedData
37+
*
38+
* @throws Throwable
39+
*/
40+
#[Test]
41+
#[DataProvider('should_be_able_to_resolve_default_values_data_provider')]
42+
public function should_be_able_to_resolve_default_values(
43+
Data $object,
44+
array $arrayData,
45+
array $expectedSerializedData
46+
): void {
47+
self::assertEquals(
48+
$expectedSerializedData,
49+
$object::from($arrayData)->toArray()
50+
);
51+
}
52+
53+
/**
54+
* @return array<string, mixed>
55+
*/
56+
public static function should_be_able_to_resolve_default_values_data_provider(): array
57+
{
58+
return [
59+
'Resolves default scalar type value' => [
60+
'object' => new readonly class ('') extends Data {
61+
public function __construct(
62+
#[DefaultsTo('John')]
63+
public string $name
64+
) {
65+
}
66+
},
67+
'arrayData' => [],
68+
'expectedSerializedData' => [
69+
'name' => 'John'
70+
]
71+
]
72+
];
73+
}
74+
}

0 commit comments

Comments
 (0)