Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 76 additions & 15 deletions src/IsModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
);
}
Expand All @@ -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<int, mixed> $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();
Expand All @@ -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)),
Expand All @@ -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)
)
);
Expand Down Expand Up @@ -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.
*
Expand Down
28 changes: 28 additions & 0 deletions tests/Fixtures/TypeSafety/FlexibleValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety;

use ComplexHeart\Domain\Contracts\Model\ValueObject;
use ComplexHeart\Domain\Model\IsValueObject;

/**
* Test fixture for complex union type scenarios
*/
final class FlexibleValue implements ValueObject
{
use IsValueObject;

public function __construct(
private readonly int|float|string $value,
private readonly string|null $label = null
) {
// Auto-check will happen via make()
}

public function __toString(): string
{
return $this->label ?? (string) $this->value;
}
}
67 changes: 65 additions & 2 deletions tests/TypeValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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"');
}
});

Expand All @@ -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');
}
});