Skip to content

Commit 27cdb14

Browse files
authored
fix: EnumCaster Backed Typing (#9)
1 parent e64b744 commit 27cdb14

File tree

4 files changed

+112
-33
lines changed

4 files changed

+112
-33
lines changed

src/Attributes/CastWith.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use function array_key_exists;
1313
use function is_array;
14+
use function is_null;
1415

1516
#[Attribute(Attribute::TARGET_PROPERTY)]
1617
/**
@@ -43,6 +44,11 @@ public function handle(string $propertyName, array &$data): void
4344

4445
$value = $data[$propertyName];
4546

47+
// If it's null return early
48+
if (is_null($value)) {
49+
return;
50+
}
51+
4652
// If using a CasterInterface implementation
4753
if ($this->caster instanceof CasterInterface) {
4854
$data[$propertyName] = $this->caster->cast($value);

src/Casters/EnumCaster.php

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Alamellama\Carapace\Contracts\CasterInterface;
88
use BackedEnum;
99
use InvalidArgumentException;
10+
use ReflectionEnum;
1011
use UnitEnum;
1112
use ValueError;
1213

@@ -39,21 +40,21 @@ public function __construct(
3940
*/
4041
public function cast(mixed $value): UnitEnum
4142
{
42-
// If already an instance of the target enum, return it
4343
if ($value instanceof $this->enumClass && $value instanceof UnitEnum) {
4444
return $value;
4545
}
4646

47-
// Check if the enum class exists
4847
if (! enum_exists($this->enumClass)) {
4948
throw new InvalidArgumentException("Invalid enum class: {$this->enumClass}");
5049
}
5150

52-
// Handle backed enums
5351
if (is_subclass_of($this->enumClass, BackedEnum::class)) {
54-
// Try to get the enum case from its value
5552
try {
56-
/** @var int|string $value */
53+
$reflectedEnum = new ReflectionEnum($this->enumClass);
54+
55+
// @phpstan-ignore-next-line
56+
$value = $reflectedEnum->getBackingType()?->getName() === 'int' ? (int) $value : (string) $value;
57+
5758
return $this->enumClass::from($value);
5859
} catch (ValueError $e) {
5960
// If the exact value doesn't exist, try to find a case that matches case-insensitively
@@ -64,15 +65,12 @@ public function cast(mixed $value): UnitEnum
6465
}
6566
}
6667
}
67-
6868
// Use tryFrom as a fallback
69-
if ($value !== null) {
70-
$result = $this->enumClass::tryFrom($value);
71-
if ($result !== null) {
72-
// @codeCoverageIgnoreStart
73-
return $result;
74-
// @codeCoverageIgnoreEnd
75-
}
69+
$result = $this->enumClass::tryFrom($value);
70+
if ($result !== null) {
71+
// @codeCoverageIgnoreStart
72+
return $result;
73+
// @codeCoverageIgnoreEnd
7674
}
7775

7876
throw new InvalidArgumentException(
@@ -83,7 +81,6 @@ public function cast(mixed $value): UnitEnum
8381
}
8482
}
8583

86-
// Handle unit enums (enums without values)
8784
// For unit enums, we need to match by name
8885
if (is_string($value)) {
8986
foreach ($this->enumClass::cases() as $case) {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures\Enums;
6+
7+
enum StatusCode: int
8+
{
9+
case PENDING = 100;
10+
case ACTIVE = 200;
11+
case INACTIVE = 300;
12+
}

tests/Unit/EnumCasterTest.php

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,54 +10,96 @@
1010
use InvalidArgumentException;
1111
use Tests\Fixtures\Enums\Color;
1212
use Tests\Fixtures\Enums\Status;
13+
use Tests\Fixtures\Enums\StatusCode;
1314

14-
final class WithEnumCasting extends ImmutableDTO
15+
final class StatusDto extends ImmutableDTO
1516
{
1617
public function __construct(
1718
#[CastWith(new EnumCaster(Status::class))]
1819
public Status $status,
20+
) {}
21+
}
1922

23+
final class ColorDto extends ImmutableDTO
24+
{
25+
public function __construct(
2026
#[CastWith(new EnumCaster(Color::class))]
21-
public Color $color
27+
public Color $color,
28+
) {}
29+
}
30+
31+
final class StatusCodeDto extends ImmutableDTO
32+
{
33+
public function __construct(
34+
#[CastWith(new EnumCaster(StatusCode::class))]
35+
public StatusCode $statusCode,
36+
) {}
37+
}
38+
39+
final class CombinedDto extends ImmutableDTO
40+
{
41+
public function __construct(
42+
#[CastWith(new EnumCaster(Status::class))]
43+
public Status $status,
44+
45+
#[CastWith(new EnumCaster(StatusCode::class))]
46+
public ?StatusCode $statusCode,
2247
) {}
2348
}
2449

2550
it('can cast string to backed enum using exact value', function (): void {
26-
$dto = WithEnumCasting::from([
51+
$dto = StatusDto::from([
2752
'status' => 'active',
28-
'color' => 'RED',
2953
]);
3054

3155
expect($dto->status)
32-
->toBe(Status::ACTIVE)
33-
->and($dto->color)
34-
->toBe(Color::RED);
56+
->toBe(Status::ACTIVE);
3557
});
3658

3759
it('can cast string to backed enum using case-insensitive value', function (): void {
38-
$dto = WithEnumCasting::from([
60+
$dto = StatusDto::from([
3961
'status' => 'ACTIVE',
40-
'color' => 'RED',
4162
]);
4263

4364
expect($dto->status)
4465
->toBe(Status::ACTIVE);
4566
});
4667

68+
it('can cast int backed enum using int value', function (): void {
69+
$dto = StatusCodeDto::from([
70+
'statusCode' => 100,
71+
]);
72+
73+
expect($dto->statusCode)
74+
->toBe(StatusCode::PENDING);
75+
});
76+
77+
it('can cast int backed enum using string value', function (): void {
78+
$dto = StatusCodeDto::from([
79+
'statusCode' => '300',
80+
]);
81+
82+
expect($dto->statusCode)
83+
->toBe(StatusCode::INACTIVE);
84+
});
85+
4786
it('can handle existing enum instances', function (): void {
48-
$dto = WithEnumCasting::from([
87+
$dto1 = StatusDto::from([
4988
'status' => Status::PENDING,
89+
]);
90+
91+
$dto2 = ColorDto::from([
5092
'color' => Color::GREEN,
5193
]);
5294

53-
expect($dto->status)
95+
expect($dto1->status)
5496
->toBe(Status::PENDING)
55-
->and($dto->color)
97+
->and($dto2->color)
5698
->toBe(Color::GREEN);
5799
});
58100

59101
it('can cast string to unit enum using case name', function (): void {
60-
$dto = WithEnumCasting::from([
102+
$dto = ColorDto::from([
61103
'status' => 'active',
62104
'color' => 'BLUE',
63105
]);
@@ -67,8 +109,7 @@ public function __construct(
67109
});
68110

69111
it('can cast string to unit enum using case-insensitive name', function (): void {
70-
$dto = WithEnumCasting::from([
71-
'status' => 'active',
112+
$dto = ColorDto::from([
72113
'color' => 'blue',
73114
]);
74115

@@ -77,15 +118,13 @@ public function __construct(
77118
});
78119

79120
it('throws exception for invalid backed enum value', function (): void {
80-
WithEnumCasting::from([
121+
StatusDto::from([
81122
'status' => 'unknown',
82-
'color' => 'RED',
83123
]);
84124
})->throws(InvalidArgumentException::class, 'Cannot cast value to enum Tests\Fixtures\Enums\Status');
85125

86126
it('throws exception for invalid unit enum name', function (): void {
87-
WithEnumCasting::from([
88-
'status' => 'active',
127+
ColorDto::from([
89128
'color' => 'YELLOW',
90129
]);
91130
})->throws(InvalidArgumentException::class, 'Cannot cast value to enum Tests\Fixtures\Enums\Color: no matching case found');
@@ -99,3 +138,28 @@ public function __construct(
99138
$caster = new EnumCaster(Color::class);
100139
$caster->cast(123);
101140
})->throws(InvalidArgumentException::class, 'Cannot cast value to enum Tests\Fixtures\Enums\Color: no matching case found');
141+
142+
it('can handle optional property', function (): void {
143+
$dto = CombinedDto::from([
144+
'status' => Status::PENDING,
145+
]);
146+
147+
expect($dto->status)
148+
->toBe(Status::PENDING);
149+
150+
expect($dto->statusCode)
151+
->toBeNull();
152+
});
153+
154+
it('can handle null property', function (): void {
155+
$dto = CombinedDto::from([
156+
'status' => Status::PENDING,
157+
'statusCode' => null,
158+
]);
159+
160+
expect($dto->status)
161+
->toBe(Status::PENDING);
162+
163+
expect($dto->statusCode)
164+
->toBeNull();
165+
});

0 commit comments

Comments
 (0)