Skip to content

Commit c744ad9

Browse files
authored
Feature/named parameter support (#22)
* Add support for named parameters in make() method * Add documentation for named parameter and union type support in factory methods
1 parent 0aa04c7 commit c744ad9

File tree

6 files changed

+188
-2
lines changed

6 files changed

+188
-2
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ On top of those base traits **Complex Heart** provide ready to use compositions:
3030

3131
- **Type-Safe Factory Method**: The `make()` static factory validates constructor parameters at runtime with clear error messages
3232
- **Automatic Invariant Checking**: When using `make()`, Value Objects and Entities automatically validate invariants after construction (no manual `$this->check()` needed)
33+
- **Named Parameter Support**: Full support for PHP 8.0+ named parameters for improved readability and flexibility
34+
- **Union Type Support**: Complete support for PHP 8.0+ union types (e.g., `int|float`, `string|null`)
3335
- **Readonly Properties Support**: Full compatibility with PHP 8.1+ readonly properties
3436
- **PHPStan Level 8**: Complete static analysis support
3537

src/IsModel.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ final public static function make(mixed ...$params): static
5656
);
5757
}
5858

59+
// Handle named parameters if provided
60+
if (self::hasNamedParameters($params)) {
61+
$params = self::mapNamedToPositional($constructor, $params);
62+
}
63+
5964
// Validate parameters against constructor signature
6065
// array_values ensures we have a proper indexed array
6166
self::validateConstructorParameters($constructor, array_values($params));
@@ -211,6 +216,74 @@ private static function validateUnionType(mixed $value, ReflectionUnionType $uni
211216
return false;
212217
}
213218

219+
/**
220+
* Check if parameters include named parameters.
221+
*
222+
* @param array<int|string, mixed> $params
223+
* @return bool
224+
*/
225+
private static function hasNamedParameters(array $params): bool
226+
{
227+
if (empty($params)) {
228+
return false;
229+
}
230+
231+
// Named parameters have string keys
232+
// Positional parameters have sequential integer keys [0, 1, 2, ...]
233+
return array_keys($params) !== range(0, count($params) - 1);
234+
}
235+
236+
/**
237+
* Map named parameters to positional parameters based on constructor signature.
238+
*
239+
* Supports three scenarios:
240+
* 1. Pure named parameters: make(value: 'test')
241+
* 2. Pure positional parameters: make('test')
242+
* 3. Mixed parameters: make(1, name: 'test', description: 'desc')
243+
*
244+
* @param ReflectionMethod $constructor
245+
* @param array<int|string, mixed> $params
246+
* @return array<int, mixed>
247+
* @throws InvalidArgumentException When required named parameter is missing
248+
*/
249+
private static function mapNamedToPositional(
250+
ReflectionMethod $constructor,
251+
array $params
252+
): array {
253+
$positional = [];
254+
$constructorParams = $constructor->getParameters();
255+
256+
foreach ($constructorParams as $index => $param) {
257+
$name = $param->getName();
258+
259+
// Check if parameter was provided positionally (by index)
260+
if (array_key_exists($index, $params)) {
261+
$positional[$index] = $params[$index];
262+
}
263+
// Check if parameter was provided by name
264+
elseif (array_key_exists($name, $params)) {
265+
$positional[$index] = $params[$name];
266+
}
267+
// Check if parameter has a default value
268+
elseif ($param->isDefaultValueAvailable()) {
269+
$positional[$index] = $param->getDefaultValue();
270+
}
271+
// Check if parameter is required
272+
elseif (!$param->isOptional()) {
273+
throw new InvalidArgumentException(
274+
sprintf(
275+
'%s::make() missing required parameter: %s',
276+
basename(str_replace('\\', '/', static::class)),
277+
$name
278+
)
279+
);
280+
}
281+
// else: optional parameter without default (e.g., nullable), will be handled by PHP
282+
}
283+
284+
return $positional;
285+
}
286+
214287
/**
215288
* Determine if invariants should be checked automatically after construction.
216289
*

tests/TypeValidationTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,65 @@
142142
->and($e->getMessage())->toContain('array given');
143143
}
144144
});
145+
146+
test('make() should accept named parameters', function () {
147+
$email = Email::make(value: 'test@example.com');
148+
149+
expect($email)->toBeInstanceOf(Email::class)
150+
->and((string) $email)->toBe('test@example.com');
151+
});
152+
153+
test('make() should accept named parameters in any order', function () {
154+
$money = Money::make(currency: 'USD', amount: 100);
155+
156+
expect($money)->toBeInstanceOf(Money::class)
157+
->and((string) $money)->toBe('100 USD');
158+
});
159+
160+
test('make() should mix named and positional parameters', function () {
161+
// First positional, rest named
162+
$model = ComplexModel::make(1, name: 'Test', description: 'Desc', tags: []);
163+
164+
expect($model)->toBeInstanceOf(ComplexModel::class);
165+
});
166+
167+
test('make() should skip optional parameters with named params', function () {
168+
// Skip optional 'label' parameter
169+
$value = FlexibleValue::make(value: 42);
170+
171+
expect($value)->toBeInstanceOf(FlexibleValue::class)
172+
->and((string) $value)->toBe('42');
173+
});
174+
175+
test('make() should use default values for omitted named params', function () {
176+
// FlexibleValue has label with default null
177+
$value = FlexibleValue::make(value: 'test');
178+
179+
expect($value)->toBeInstanceOf(FlexibleValue::class);
180+
});
181+
182+
test('make() should throw error for missing required named parameter', function () {
183+
Money::make(amount: 100);
184+
})->throws(InvalidArgumentException::class, 'missing required parameter: currency');
185+
186+
test('make() should validate types with named parameters', function () {
187+
Email::make(value: 123);
188+
})->throws(TypeError::class, 'parameter "value" must be of type string, int given');
189+
190+
test('make() should handle nullable types with named parameters', function () {
191+
$model = ComplexModel::make(id: 1, name: 'Test', description: null, tags: []);
192+
193+
expect($model)->toBeInstanceOf(ComplexModel::class);
194+
});
195+
196+
test('make() should handle union types with named parameters', function () {
197+
$money1 = Money::make(amount: 100, currency: 'USD');
198+
$money2 = Money::make(amount: 99.99, currency: 'EUR');
199+
200+
expect($money1)->toBeInstanceOf(Money::class)
201+
->and($money2)->toBeInstanceOf(Money::class);
202+
});
203+
204+
test('make() should validate union types with named parameters', function () {
205+
Money::make(amount: 'invalid', currency: 'USD');
206+
})->throws(TypeError::class, 'parameter "amount" must be of type int|float');

wiki/Domain-Modeling-Aggregates.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,14 @@ final class Order implements Aggregate
8787

8888
**Benefits of using `make()` in factory methods:**
8989
- Automatic invariant checking when using `make()`
90-
- Type validation at runtime
90+
- Type validation at runtime with clear error messages
91+
- Named parameter support for improved readability (as shown above)
92+
- Union type support (e.g., `int|float`, `string|null`)
9193
- Cleaner factory method code
9294
- Consistent with Value Objects and Entities
9395

96+
**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.
97+
9498
**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.
9599

96100
#### Alternative: Direct Constructor with Manual Check

wiki/Domain-Modeling-Entities.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,19 @@ final class Customer implements Entity
4949

5050
// Type-safe instantiation with automatic invariant validation
5151
$customer = Customer::make(UUIDValue::random(), 'Vincent Vega');
52+
53+
// Named parameters for improved readability (PHP 8.0+)
54+
$customer = Customer::make(
55+
id: UUIDValue::random(),
56+
name: 'Vincent Vega'
57+
);
5258
```
5359

5460
**Benefits:**
5561
- Automatic invariant checking when using `make()`
56-
- Type validation at runtime
62+
- Type validation at runtime with clear error messages
63+
- Named parameter support for improved readability
64+
- Union type support (e.g., `int|float`, `string|null`)
5765
- Cleaner constructor code
5866

5967
**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.

wiki/Domain-Modeling-Value-Objects.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,53 @@ class Email implements ValueObject
9292
$email = Email::make('user@example.com'); // ✅ Valid
9393
$email = Email::make(123); // ❌ TypeError: parameter "value" must be of type string, int given
9494
$email = Email::make('invalid'); // ❌ InvariantViolation: Valid format
95+
96+
// Named parameters for improved readability (PHP 8.0+)
97+
$email = Email::make(value: 'user@example.com'); // ✅ Self-documenting code
9598
```
9699

97100
**Benefits of `make()`:**
98101
- Runtime type validation with clear error messages
99102
- Automatic invariant checking after construction
103+
- Named parameter support for improved readability
104+
- Union type support (e.g., `int|float`, `string|null`)
100105
- Works seamlessly with readonly properties
101106
- PHPStan level 8 compliant
102107

103108
**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.
104109

110+
#### Named Parameters Example
111+
112+
Named parameters (PHP 8.0+) make code more readable and allow parameters in any order:
113+
114+
```php
115+
final class Money implements ValueObject
116+
{
117+
use IsValueObject;
118+
119+
public function __construct(
120+
private readonly int|float $amount,
121+
private readonly string $currency
122+
) {}
123+
124+
protected function invariantPositiveAmount(): bool
125+
{
126+
return $this->amount > 0;
127+
}
128+
129+
public function __toString(): string
130+
{
131+
return sprintf('%s %s', $this->amount, $this->currency);
132+
}
133+
}
134+
135+
// All equivalent, choose the most readable for your context:
136+
$money = Money::make(100, 'USD'); // Positional
137+
$money = Money::make(amount: 100, currency: 'USD'); // Named
138+
$money = Money::make(currency: 'USD', amount: 100); // Named, different order
139+
$money = Money::make(100, currency: 'USD'); // Mixed
140+
```
141+
105142
#### Alternative: Constructor Property Promotion with Manual Check
106143

107144
If you prefer direct constructor calls, you **must** manually call `$this->check()`:

0 commit comments

Comments
 (0)