From 9d390117b4fdac0b01954383b94eae2a8db8fd2f Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Mon, 13 Oct 2025 12:07:23 +1000 Subject: [PATCH] Feature/Add support for union types in IsModel trait --- src/IsModel.php | 91 +++++++++++++++++---- tests/Fixtures/TypeSafety/FlexibleValue.php | 28 +++++++ tests/TypeValidationTest.php | 67 ++++++++++++++- 3 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 tests/Fixtures/TypeSafety/FlexibleValue.php diff --git a/src/IsModel.php b/src/IsModel.php index 467f4df..03931c1 100644 --- a/src/IsModel.php +++ b/src/IsModel.php @@ -6,6 +6,13 @@ use ComplexHeart\Domain\Model\Traits\HasAttributes; use ComplexHeart\Domain\Model\Traits\HasInvariants; +use InvalidArgumentException; +use ReflectionClass; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionUnionType; +use RuntimeException; +use TypeError; /** * Trait IsModel @@ -35,16 +42,16 @@ trait IsModel * * @param mixed ...$params Constructor parameters * @return static - * @throws \InvalidArgumentException When required parameters are missing - * @throws \TypeError When parameter types don't match + * @throws InvalidArgumentException When required parameters are missing + * @throws TypeError When parameter types don't match */ final public static function make(mixed ...$params): static { - $reflection = new \ReflectionClass(static::class); + $reflection = new ReflectionClass(static::class); $constructor = $reflection->getConstructor(); if (!$constructor) { - throw new \RuntimeException( + throw new RuntimeException( sprintf('%s must have a constructor to use make()', static::class) ); } @@ -66,14 +73,14 @@ final public static function make(mixed ...$params): static /** * Validate parameters match constructor signature. * - * @param \ReflectionMethod $constructor + * @param ReflectionMethod $constructor * @param array $params * @return void - * @throws \InvalidArgumentException - * @throws \TypeError + * @throws InvalidArgumentException + * @throws TypeError */ private static function validateConstructorParameters( - \ReflectionMethod $constructor, + ReflectionMethod $constructor, array $params ): void { $constructorParams = $constructor->getParameters(); @@ -83,7 +90,7 @@ private static function validateConstructorParameters( if (count($params) < $required) { $missing = array_slice($constructorParams, count($params), $required - count($params)); $names = array_map(fn ($p) => $p->getName(), $missing); - throw new \InvalidArgumentException( + throw new InvalidArgumentException( sprintf( '%s::make() missing required parameters: %s', basename(str_replace('\\', '/', static::class)), @@ -101,20 +108,35 @@ private static function validateConstructorParameters( $value = $params[$index]; $type = $param->getType(); - if (!$type instanceof \ReflectionNamedType) { - continue; // No type hint or union type + if ($type === null) { + continue; // No type hint } - $typeName = $type->getName(); - $isValid = self::validateType($value, $typeName, $type->allowsNull()); + $isValid = false; + $expectedTypes = ''; + + if ($type instanceof ReflectionNamedType) { + // Single type + $isValid = self::validateType($value, $type->getName(), $type->allowsNull()); + $expectedTypes = $type->getName(); + } elseif ($type instanceof ReflectionUnionType) { + // Union type (e.g., int|float|string) + $isValid = self::validateUnionType($value, $type); + $expectedTypes = implode('|', array_map( + fn($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed', + $type->getTypes() + )); + } else { + continue; // Intersection types or other complex types not supported yet + } if (!$isValid) { - throw new \TypeError( + throw new TypeError( sprintf( '%s::make() parameter "%s" must be of type %s, %s given', basename(str_replace('\\', '/', static::class)), $param->getName(), - $typeName, + $expectedTypes, get_debug_type($value) ) ); @@ -150,6 +172,45 @@ private static function validateType(mixed $value, string $typeName, bool $allow }; } + /** + * Validate a value matches one of the types in a union type. + * + * @param mixed $value + * @param ReflectionUnionType $unionType + * @return bool + */ + private static function validateUnionType(mixed $value, ReflectionUnionType $unionType): bool + { + // Check if null is allowed in the union + $allowsNull = $unionType->allowsNull(); + + if ($value === null) { + return $allowsNull; + } + + // Try to match against each type in the union + foreach ($unionType->getTypes() as $type) { + if (!$type instanceof ReflectionNamedType) { + continue; // Skip non-named types (shouldn't happen in practice) + } + + $typeName = $type->getName(); + + // Skip 'null' type as we already handled it + if ($typeName === 'null') { + continue; + } + + // If value matches this type, union is satisfied + if (self::validateType($value, $typeName, false)) { + return true; + } + } + + // Value didn't match any type in the union + return false; + } + /** * Determine if invariants should be checked automatically after construction. * diff --git a/tests/Fixtures/TypeSafety/FlexibleValue.php b/tests/Fixtures/TypeSafety/FlexibleValue.php new file mode 100644 index 0000000..de21eac --- /dev/null +++ b/tests/Fixtures/TypeSafety/FlexibleValue.php @@ -0,0 +1,28 @@ +label ?? (string) $this->value; + } +} diff --git a/tests/TypeValidationTest.php b/tests/TypeValidationTest.php index 394432a..4993049 100644 --- a/tests/TypeValidationTest.php +++ b/tests/TypeValidationTest.php @@ -5,6 +5,7 @@ use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Email; use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Money; use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\ComplexModel; +use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\FlexibleValue; test('make() should create instance with valid types', function () { $email = Email::make('test@example.com'); @@ -62,8 +63,7 @@ try { Money::make('invalid', 'USD'); } catch (TypeError $e) { - // PHP's native error uses "Argument" not "parameter" - expect($e->getMessage())->toContain('$amount'); + expect($e->getMessage())->toContain('parameter "amount"'); } }); @@ -79,3 +79,66 @@ // Direct constructor call with wrong type will fail at PHP level new Email(123); })->throws(TypeError::class); + +test('make() should accept int for int|float union type', function () { + $money = Money::make(100, 'USD'); + + expect($money)->toBeInstanceOf(Money::class) + ->and((string) $money)->toBe('100 USD'); +}); + +test('make() should accept float for int|float union type', function () { + $money = Money::make(99.99, 'EUR'); + + expect($money)->toBeInstanceOf(Money::class) + ->and((string) $money)->toBe('99.99 EUR'); +}); + +test('make() should accept int for int|float|string union type', function () { + $value = FlexibleValue::make(42); + + expect($value)->toBeInstanceOf(FlexibleValue::class) + ->and((string) $value)->toBe('42'); +}); + +test('make() should accept float for int|float|string union type', function () { + $value = FlexibleValue::make(3.14); + + expect($value)->toBeInstanceOf(FlexibleValue::class) + ->and((string) $value)->toBe('3.14'); +}); + +test('make() should accept string for int|float|string union type', function () { + $value = FlexibleValue::make('text'); + + expect($value)->toBeInstanceOf(FlexibleValue::class) + ->and((string) $value)->toBe('text'); +}); + +test('make() should reject invalid type for union type', function () { + Money::make(['not', 'valid'], 'USD'); +})->throws(TypeError::class, 'parameter "amount" must be of type int|float'); + +test('make() should handle nullable union types', function () { + $value1 = FlexibleValue::make(42, 'Label'); + $value2 = FlexibleValue::make(42, null); + + expect($value1)->toBeInstanceOf(FlexibleValue::class) + ->and($value2)->toBeInstanceOf(FlexibleValue::class); +}); + +test('make() should accept null for nullable union type', function () { + $value = FlexibleValue::make('test', null); + + expect($value)->toBeInstanceOf(FlexibleValue::class); +}); + +test('make() union type error shows all possible types', function () { + try { + FlexibleValue::make(['array']); + } catch (TypeError $e) { + // Union type order depends on PHP's internal representation + expect($e->getMessage())->toMatch('/int\|float\|string|string\|int\|float/') + ->and($e->getMessage())->toContain('array given'); + } +});