Skip to content

Commit 0aa04c7

Browse files
authored
Feature/Add support for union types in IsModel trait (#21)
1 parent b590a5f commit 0aa04c7

File tree

3 files changed

+169
-17
lines changed

3 files changed

+169
-17
lines changed

src/IsModel.php

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
use ComplexHeart\Domain\Model\Traits\HasAttributes;
88
use ComplexHeart\Domain\Model\Traits\HasInvariants;
9+
use InvalidArgumentException;
10+
use ReflectionClass;
11+
use ReflectionMethod;
12+
use ReflectionNamedType;
13+
use ReflectionUnionType;
14+
use RuntimeException;
15+
use TypeError;
916

1017
/**
1118
* Trait IsModel
@@ -35,16 +42,16 @@ trait IsModel
3542
*
3643
* @param mixed ...$params Constructor parameters
3744
* @return static
38-
* @throws \InvalidArgumentException When required parameters are missing
39-
* @throws \TypeError When parameter types don't match
45+
* @throws InvalidArgumentException When required parameters are missing
46+
* @throws TypeError When parameter types don't match
4047
*/
4148
final public static function make(mixed ...$params): static
4249
{
43-
$reflection = new \ReflectionClass(static::class);
50+
$reflection = new ReflectionClass(static::class);
4451
$constructor = $reflection->getConstructor();
4552

4653
if (!$constructor) {
47-
throw new \RuntimeException(
54+
throw new RuntimeException(
4855
sprintf('%s must have a constructor to use make()', static::class)
4956
);
5057
}
@@ -66,14 +73,14 @@ final public static function make(mixed ...$params): static
6673
/**
6774
* Validate parameters match constructor signature.
6875
*
69-
* @param \ReflectionMethod $constructor
76+
* @param ReflectionMethod $constructor
7077
* @param array<int, mixed> $params
7178
* @return void
72-
* @throws \InvalidArgumentException
73-
* @throws \TypeError
79+
* @throws InvalidArgumentException
80+
* @throws TypeError
7481
*/
7582
private static function validateConstructorParameters(
76-
\ReflectionMethod $constructor,
83+
ReflectionMethod $constructor,
7784
array $params
7885
): void {
7986
$constructorParams = $constructor->getParameters();
@@ -83,7 +90,7 @@ private static function validateConstructorParameters(
8390
if (count($params) < $required) {
8491
$missing = array_slice($constructorParams, count($params), $required - count($params));
8592
$names = array_map(fn ($p) => $p->getName(), $missing);
86-
throw new \InvalidArgumentException(
93+
throw new InvalidArgumentException(
8794
sprintf(
8895
'%s::make() missing required parameters: %s',
8996
basename(str_replace('\\', '/', static::class)),
@@ -101,20 +108,35 @@ private static function validateConstructorParameters(
101108
$value = $params[$index];
102109
$type = $param->getType();
103110

104-
if (!$type instanceof \ReflectionNamedType) {
105-
continue; // No type hint or union type
111+
if ($type === null) {
112+
continue; // No type hint
106113
}
107114

108-
$typeName = $type->getName();
109-
$isValid = self::validateType($value, $typeName, $type->allowsNull());
115+
$isValid = false;
116+
$expectedTypes = '';
117+
118+
if ($type instanceof ReflectionNamedType) {
119+
// Single type
120+
$isValid = self::validateType($value, $type->getName(), $type->allowsNull());
121+
$expectedTypes = $type->getName();
122+
} elseif ($type instanceof ReflectionUnionType) {
123+
// Union type (e.g., int|float|string)
124+
$isValid = self::validateUnionType($value, $type);
125+
$expectedTypes = implode('|', array_map(
126+
fn($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
127+
$type->getTypes()
128+
));
129+
} else {
130+
continue; // Intersection types or other complex types not supported yet
131+
}
110132

111133
if (!$isValid) {
112-
throw new \TypeError(
134+
throw new TypeError(
113135
sprintf(
114136
'%s::make() parameter "%s" must be of type %s, %s given',
115137
basename(str_replace('\\', '/', static::class)),
116138
$param->getName(),
117-
$typeName,
139+
$expectedTypes,
118140
get_debug_type($value)
119141
)
120142
);
@@ -150,6 +172,45 @@ private static function validateType(mixed $value, string $typeName, bool $allow
150172
};
151173
}
152174

175+
/**
176+
* Validate a value matches one of the types in a union type.
177+
*
178+
* @param mixed $value
179+
* @param ReflectionUnionType $unionType
180+
* @return bool
181+
*/
182+
private static function validateUnionType(mixed $value, ReflectionUnionType $unionType): bool
183+
{
184+
// Check if null is allowed in the union
185+
$allowsNull = $unionType->allowsNull();
186+
187+
if ($value === null) {
188+
return $allowsNull;
189+
}
190+
191+
// Try to match against each type in the union
192+
foreach ($unionType->getTypes() as $type) {
193+
if (!$type instanceof ReflectionNamedType) {
194+
continue; // Skip non-named types (shouldn't happen in practice)
195+
}
196+
197+
$typeName = $type->getName();
198+
199+
// Skip 'null' type as we already handled it
200+
if ($typeName === 'null') {
201+
continue;
202+
}
203+
204+
// If value matches this type, union is satisfied
205+
if (self::validateType($value, $typeName, false)) {
206+
return true;
207+
}
208+
}
209+
210+
// Value didn't match any type in the union
211+
return false;
212+
}
213+
153214
/**
154215
* Determine if invariants should be checked automatically after construction.
155216
*
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety;
6+
7+
use ComplexHeart\Domain\Contracts\Model\ValueObject;
8+
use ComplexHeart\Domain\Model\IsValueObject;
9+
10+
/**
11+
* Test fixture for complex union type scenarios
12+
*/
13+
final class FlexibleValue implements ValueObject
14+
{
15+
use IsValueObject;
16+
17+
public function __construct(
18+
private readonly int|float|string $value,
19+
private readonly string|null $label = null
20+
) {
21+
// Auto-check will happen via make()
22+
}
23+
24+
public function __toString(): string
25+
{
26+
return $this->label ?? (string) $this->value;
27+
}
28+
}

tests/TypeValidationTest.php

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Email;
66
use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Money;
77
use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\ComplexModel;
8+
use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\FlexibleValue;
89

910
test('make() should create instance with valid types', function () {
1011
$email = Email::make('test@example.com');
@@ -62,8 +63,7 @@
6263
try {
6364
Money::make('invalid', 'USD');
6465
} catch (TypeError $e) {
65-
// PHP's native error uses "Argument" not "parameter"
66-
expect($e->getMessage())->toContain('$amount');
66+
expect($e->getMessage())->toContain('parameter "amount"');
6767
}
6868
});
6969

@@ -79,3 +79,66 @@
7979
// Direct constructor call with wrong type will fail at PHP level
8080
new Email(123);
8181
})->throws(TypeError::class);
82+
83+
test('make() should accept int for int|float union type', function () {
84+
$money = Money::make(100, 'USD');
85+
86+
expect($money)->toBeInstanceOf(Money::class)
87+
->and((string) $money)->toBe('100 USD');
88+
});
89+
90+
test('make() should accept float for int|float union type', function () {
91+
$money = Money::make(99.99, 'EUR');
92+
93+
expect($money)->toBeInstanceOf(Money::class)
94+
->and((string) $money)->toBe('99.99 EUR');
95+
});
96+
97+
test('make() should accept int for int|float|string union type', function () {
98+
$value = FlexibleValue::make(42);
99+
100+
expect($value)->toBeInstanceOf(FlexibleValue::class)
101+
->and((string) $value)->toBe('42');
102+
});
103+
104+
test('make() should accept float for int|float|string union type', function () {
105+
$value = FlexibleValue::make(3.14);
106+
107+
expect($value)->toBeInstanceOf(FlexibleValue::class)
108+
->and((string) $value)->toBe('3.14');
109+
});
110+
111+
test('make() should accept string for int|float|string union type', function () {
112+
$value = FlexibleValue::make('text');
113+
114+
expect($value)->toBeInstanceOf(FlexibleValue::class)
115+
->and((string) $value)->toBe('text');
116+
});
117+
118+
test('make() should reject invalid type for union type', function () {
119+
Money::make(['not', 'valid'], 'USD');
120+
})->throws(TypeError::class, 'parameter "amount" must be of type int|float');
121+
122+
test('make() should handle nullable union types', function () {
123+
$value1 = FlexibleValue::make(42, 'Label');
124+
$value2 = FlexibleValue::make(42, null);
125+
126+
expect($value1)->toBeInstanceOf(FlexibleValue::class)
127+
->and($value2)->toBeInstanceOf(FlexibleValue::class);
128+
});
129+
130+
test('make() should accept null for nullable union type', function () {
131+
$value = FlexibleValue::make('test', null);
132+
133+
expect($value)->toBeInstanceOf(FlexibleValue::class);
134+
});
135+
136+
test('make() union type error shows all possible types', function () {
137+
try {
138+
FlexibleValue::make(['array']);
139+
} catch (TypeError $e) {
140+
// Union type order depends on PHP's internal representation
141+
expect($e->getMessage())->toMatch('/int\|float\|string|string\|int\|float/')
142+
->and($e->getMessage())->toContain('array given');
143+
}
144+
});

0 commit comments

Comments
 (0)