Skip to content

Commit 3954b1e

Browse files
committed
Feature/Add support for named parameters in make() method
1 parent 0aa04c7 commit 3954b1e

File tree

2 files changed

+135
-0
lines changed

2 files changed

+135
-0
lines changed

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');

0 commit comments

Comments
 (0)