diff --git a/README.md b/README.md index 580b5f0..757abcf 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ On top of those base traits **Complex Heart** provide ready to use compositions: - **Type-Safe Factory Method**: The `make()` static factory validates constructor parameters at runtime with clear error messages - **Automatic Invariant Checking**: When using `make()`, Value Objects and Entities automatically validate invariants after construction (no manual `$this->check()` needed) +- **Named Parameter Support**: Full support for PHP 8.0+ named parameters for improved readability and flexibility +- **Union Type Support**: Complete support for PHP 8.0+ union types (e.g., `int|float`, `string|null`) - **Readonly Properties Support**: Full compatibility with PHP 8.1+ readonly properties - **PHPStan Level 8**: Complete static analysis support diff --git a/src/IsModel.php b/src/IsModel.php index 03931c1..1b56146 100644 --- a/src/IsModel.php +++ b/src/IsModel.php @@ -56,6 +56,11 @@ final public static function make(mixed ...$params): static ); } + // Handle named parameters if provided + if (self::hasNamedParameters($params)) { + $params = self::mapNamedToPositional($constructor, $params); + } + // Validate parameters against constructor signature // array_values ensures we have a proper indexed array self::validateConstructorParameters($constructor, array_values($params)); @@ -211,6 +216,74 @@ private static function validateUnionType(mixed $value, ReflectionUnionType $uni return false; } + /** + * Check if parameters include named parameters. + * + * @param array $params + * @return bool + */ + private static function hasNamedParameters(array $params): bool + { + if (empty($params)) { + return false; + } + + // Named parameters have string keys + // Positional parameters have sequential integer keys [0, 1, 2, ...] + return array_keys($params) !== range(0, count($params) - 1); + } + + /** + * Map named parameters to positional parameters based on constructor signature. + * + * Supports three scenarios: + * 1. Pure named parameters: make(value: 'test') + * 2. Pure positional parameters: make('test') + * 3. Mixed parameters: make(1, name: 'test', description: 'desc') + * + * @param ReflectionMethod $constructor + * @param array $params + * @return array + * @throws InvalidArgumentException When required named parameter is missing + */ + private static function mapNamedToPositional( + ReflectionMethod $constructor, + array $params + ): array { + $positional = []; + $constructorParams = $constructor->getParameters(); + + foreach ($constructorParams as $index => $param) { + $name = $param->getName(); + + // Check if parameter was provided positionally (by index) + if (array_key_exists($index, $params)) { + $positional[$index] = $params[$index]; + } + // Check if parameter was provided by name + elseif (array_key_exists($name, $params)) { + $positional[$index] = $params[$name]; + } + // Check if parameter has a default value + elseif ($param->isDefaultValueAvailable()) { + $positional[$index] = $param->getDefaultValue(); + } + // Check if parameter is required + elseif (!$param->isOptional()) { + throw new InvalidArgumentException( + sprintf( + '%s::make() missing required parameter: %s', + basename(str_replace('\\', '/', static::class)), + $name + ) + ); + } + // else: optional parameter without default (e.g., nullable), will be handled by PHP + } + + return $positional; + } + /** * Determine if invariants should be checked automatically after construction. * diff --git a/tests/TypeValidationTest.php b/tests/TypeValidationTest.php index 4993049..fa4e528 100644 --- a/tests/TypeValidationTest.php +++ b/tests/TypeValidationTest.php @@ -142,3 +142,65 @@ ->and($e->getMessage())->toContain('array given'); } }); + +test('make() should accept named parameters', function () { + $email = Email::make(value: 'test@example.com'); + + expect($email)->toBeInstanceOf(Email::class) + ->and((string) $email)->toBe('test@example.com'); +}); + +test('make() should accept named parameters in any order', function () { + $money = Money::make(currency: 'USD', amount: 100); + + expect($money)->toBeInstanceOf(Money::class) + ->and((string) $money)->toBe('100 USD'); +}); + +test('make() should mix named and positional parameters', function () { + // First positional, rest named + $model = ComplexModel::make(1, name: 'Test', description: 'Desc', tags: []); + + expect($model)->toBeInstanceOf(ComplexModel::class); +}); + +test('make() should skip optional parameters with named params', function () { + // Skip optional 'label' parameter + $value = FlexibleValue::make(value: 42); + + expect($value)->toBeInstanceOf(FlexibleValue::class) + ->and((string) $value)->toBe('42'); +}); + +test('make() should use default values for omitted named params', function () { + // FlexibleValue has label with default null + $value = FlexibleValue::make(value: 'test'); + + expect($value)->toBeInstanceOf(FlexibleValue::class); +}); + +test('make() should throw error for missing required named parameter', function () { + Money::make(amount: 100); +})->throws(InvalidArgumentException::class, 'missing required parameter: currency'); + +test('make() should validate types with named parameters', function () { + Email::make(value: 123); +})->throws(TypeError::class, 'parameter "value" must be of type string, int given'); + +test('make() should handle nullable types with named parameters', function () { + $model = ComplexModel::make(id: 1, name: 'Test', description: null, tags: []); + + expect($model)->toBeInstanceOf(ComplexModel::class); +}); + +test('make() should handle union types with named parameters', function () { + $money1 = Money::make(amount: 100, currency: 'USD'); + $money2 = Money::make(amount: 99.99, currency: 'EUR'); + + expect($money1)->toBeInstanceOf(Money::class) + ->and($money2)->toBeInstanceOf(Money::class); +}); + +test('make() should validate union types with named parameters', function () { + Money::make(amount: 'invalid', currency: 'USD'); +})->throws(TypeError::class, 'parameter "amount" must be of type int|float'); diff --git a/wiki/Domain-Modeling-Aggregates.md b/wiki/Domain-Modeling-Aggregates.md index b682272..7fc3cd5 100644 --- a/wiki/Domain-Modeling-Aggregates.md +++ b/wiki/Domain-Modeling-Aggregates.md @@ -87,10 +87,14 @@ final class Order implements Aggregate **Benefits of using `make()` in factory methods:** - Automatic invariant checking when using `make()` -- Type validation at runtime +- Type validation at runtime with clear error messages +- Named parameter support for improved readability (as shown above) +- Union type support (e.g., `int|float`, `string|null`) - Cleaner factory method code - Consistent with Value Objects and Entities +**Why named parameters?** As shown in the example above, using named parameters (`reference:`, `customer:`, etc.) makes the code self-documenting and prevents parameter mix-ups, especially important in Aggregates with many constructor parameters. + **Important:** Auto-check ONLY works when using `make()`. In the alternative approach using direct constructor calls, you must manually call `$this->check()` inside the constructor. #### Alternative: Direct Constructor with Manual Check diff --git a/wiki/Domain-Modeling-Entities.md b/wiki/Domain-Modeling-Entities.md index 64f290a..5493d8c 100644 --- a/wiki/Domain-Modeling-Entities.md +++ b/wiki/Domain-Modeling-Entities.md @@ -49,11 +49,19 @@ final class Customer implements Entity // Type-safe instantiation with automatic invariant validation $customer = Customer::make(UUIDValue::random(), 'Vincent Vega'); + +// Named parameters for improved readability (PHP 8.0+) +$customer = Customer::make( + id: UUIDValue::random(), + name: 'Vincent Vega' +); ``` **Benefits:** - Automatic invariant checking when using `make()` -- Type validation at runtime +- Type validation at runtime with clear error messages +- Named parameter support for improved readability +- Union type support (e.g., `int|float`, `string|null`) - Cleaner constructor code **Important:** Auto-check ONLY works when using `make()`. If you call the constructor directly (`new Customer(...)`), you must manually call `$this->check()` inside the constructor. diff --git a/wiki/Domain-Modeling-Value-Objects.md b/wiki/Domain-Modeling-Value-Objects.md index ad4fc12..d2a0d9f 100644 --- a/wiki/Domain-Modeling-Value-Objects.md +++ b/wiki/Domain-Modeling-Value-Objects.md @@ -92,16 +92,53 @@ class Email implements ValueObject $email = Email::make('user@example.com'); // ✅ Valid $email = Email::make(123); // ❌ TypeError: parameter "value" must be of type string, int given $email = Email::make('invalid'); // ❌ InvariantViolation: Valid format + +// Named parameters for improved readability (PHP 8.0+) +$email = Email::make(value: 'user@example.com'); // ✅ Self-documenting code ``` **Benefits of `make()`:** - Runtime type validation with clear error messages - Automatic invariant checking after construction +- Named parameter support for improved readability +- Union type support (e.g., `int|float`, `string|null`) - Works seamlessly with readonly properties - PHPStan level 8 compliant **Important:** Auto-check ONLY works when using `make()`. Direct constructor calls do NOT trigger automatic invariant checking, so you must manually call `$this->check()` in the constructor. +#### Named Parameters Example + +Named parameters (PHP 8.0+) make code more readable and allow parameters in any order: + +```php +final class Money implements ValueObject +{ + use IsValueObject; + + public function __construct( + private readonly int|float $amount, + private readonly string $currency + ) {} + + protected function invariantPositiveAmount(): bool + { + return $this->amount > 0; + } + + public function __toString(): string + { + return sprintf('%s %s', $this->amount, $this->currency); + } +} + +// All equivalent, choose the most readable for your context: +$money = Money::make(100, 'USD'); // Positional +$money = Money::make(amount: 100, currency: 'USD'); // Named +$money = Money::make(currency: 'USD', amount: 100); // Named, different order +$money = Money::make(100, currency: 'USD'); // Mixed +``` + #### Alternative: Constructor Property Promotion with Manual Check If you prefer direct constructor calls, you **must** manually call `$this->check()`: