Skip to content

Commit 7af942d

Browse files
committed
object-constructor-schema
1 parent 6eafd3e commit 7af942d

2 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chubbyphp\Parsing\Schema;
6+
7+
use Chubbyphp\Parsing\Error;
8+
use Chubbyphp\Parsing\Errors;
9+
use Chubbyphp\Parsing\ErrorsException;
10+
11+
final class ObjectConstructorSchema extends AbstractObjectSchema implements ObjectSchemaInterface
12+
{
13+
public const string ERROR_TYPE_CODE = 'object.type';
14+
public const string ERROR_UNKNOWN_FIELD_CODE = 'object.unknownField';
15+
16+
public const string ERROR_PARAMETER_TYPE_CODE = 'object.parameterType';
17+
public const string ERROR_PARAMETER_TYPE_TEMPLATE = 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given';
18+
19+
private readonly string $typeErrorPattern;
20+
21+
/**
22+
* @param array<mixed, mixed> $fieldToSchema
23+
* @param class-string $classname
24+
*/
25+
public function __construct(array $fieldToSchema, private string $classname)
26+
{
27+
try {
28+
$reflectionClass = new \ReflectionClass($this->classname);
29+
} catch (\ReflectionException) {
30+
throw new \InvalidArgumentException('Class "'.$classname.'" does not exist or cannot be used for reflection');
31+
}
32+
33+
try {
34+
$constructorReflectionMethod = $reflectionClass->getMethod('__construct');
35+
} catch (\ReflectionException) {
36+
throw new \InvalidArgumentException('Class "'.$classname.'" does not have a __construct method');
37+
}
38+
39+
$parameterFieldToSchema = [];
40+
41+
/** @var list<string> $missingFieldToSchema */
42+
$missingFieldToSchema = [];
43+
foreach ($constructorReflectionMethod->getParameters() as $parameterReflection) {
44+
$name = $parameterReflection->getName();
45+
46+
if (isset($fieldToSchema[$name])) {
47+
$parameterFieldToSchema[$name] = $fieldToSchema[$name];
48+
49+
unset($fieldToSchema[$name]);
50+
} elseif (!$parameterReflection->isOptional()) {
51+
$missingFieldToSchema[] = $name;
52+
}
53+
}
54+
55+
if ([] !== $missingFieldToSchema) {
56+
throw new \InvalidArgumentException('Missing fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', $missingFieldToSchema).'"');
57+
}
58+
59+
if ([] !== $fieldToSchema) {
60+
throw new \InvalidArgumentException('Additional fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', array_keys($fieldToSchema)).'"');
61+
}
62+
63+
$this->typeErrorPattern = \sprintf('/%s::__construct\(\): Argument #(\d+) \(([^)]+)\) must be of type ([^ ]+), ([^ ]+) given/', preg_quote($this->classname, '/'));
64+
65+
parent::__construct($parameterFieldToSchema);
66+
}
67+
68+
/**
69+
* @param array<string, mixed> $input
70+
*/
71+
protected function parseFields(array $input, Errors $childrenErrors): ?object
72+
{
73+
$parameters = [];
74+
75+
foreach ($this->getFieldToSchema() as $fieldName => $fieldSchema) {
76+
try {
77+
if ($this->skip($input, $fieldName)) {
78+
continue;
79+
}
80+
81+
$parameters[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null);
82+
} catch (ErrorsException $e) {
83+
$childrenErrors->add($e->errors, $fieldName);
84+
}
85+
}
86+
87+
try {
88+
return new ($this->classname)(...$parameters);
89+
} catch (\TypeError $e) {
90+
$matches = [];
91+
92+
if (1 === preg_match($this->typeErrorPattern, $e->getMessage(), $matches)) {
93+
throw new ErrorsException(
94+
new Error(
95+
self::ERROR_PARAMETER_TYPE_CODE,
96+
self::ERROR_PARAMETER_TYPE_TEMPLATE,
97+
['index' => $matches[1], 'name' => $matches[2], 'type' => $matches[3], 'given' => $matches[4]]
98+
)
99+
);
100+
}
101+
102+
throw $e;
103+
}
104+
}
105+
}
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chubbyphp\Tests\Parsing\Unit\Schema;
6+
7+
use Chubbyphp\Parsing\ErrorsException;
8+
use Chubbyphp\Parsing\Schema\FloatSchema;
9+
use Chubbyphp\Parsing\Schema\IntSchema;
10+
use Chubbyphp\Parsing\Schema\ObjectConstructorSchema;
11+
use Chubbyphp\Parsing\Schema\StringSchema;
12+
use PHPUnit\Framework\TestCase;
13+
14+
final class ObjectConstructorDemo implements \JsonSerializable
15+
{
16+
public function __construct(
17+
public readonly string $field1,
18+
public readonly int $field2,
19+
public readonly ?float $field3 = null,
20+
) {}
21+
22+
public function jsonSerialize(): array
23+
{
24+
return [
25+
'field1' => $this->field1,
26+
'field2' => $this->field2,
27+
'field3' => $this->field3,
28+
];
29+
}
30+
}
31+
32+
final class ObjectConstructorThrowingTypeErrorDemo
33+
{
34+
public function __construct(
35+
public readonly string $field1,
36+
) {
37+
throw new \TypeError('some unrelated type error');
38+
}
39+
}
40+
41+
/**
42+
* @covers \Chubbyphp\Parsing\Schema\ObjectConstructorSchema
43+
*
44+
* @internal
45+
*/
46+
final class ObjectConstructorSchemaTest extends TestCase
47+
{
48+
public function testImmutability(): void
49+
{
50+
$schema = new ObjectConstructorSchema(['field1' => new StringSchema(), 'field2' => new IntSchema(), 'field3' => new FloatSchema()], ObjectConstructorDemo::class);
51+
52+
self::assertNotSame($schema, $schema->nullable());
53+
self::assertNotSame($schema, $schema->nullable(false));
54+
self::assertNotSame($schema, $schema->default([]));
55+
self::assertNotSame($schema, $schema->preParse(static fn (mixed $input) => $input));
56+
self::assertNotSame($schema, $schema->postParse(static fn (\stdClass $output) => $output));
57+
self::assertNotSame($schema, $schema->catch(static fn (\stdClass $output, ErrorsException $e) => $output));
58+
59+
self::assertNotSame($schema, $schema->strict());
60+
self::assertNotSame($schema, $schema->optional([]));
61+
}
62+
63+
public function testConstructWithClassname(): void
64+
{
65+
try {
66+
new ObjectConstructorSchema([], 'UnknownClass');
67+
68+
throw new \Exception('code should not be reached');
69+
} catch (\InvalidArgumentException $invalidArgumentException) {
70+
self::assertSame(
71+
'Class "UnknownClass" does not exist or cannot be used for reflection',
72+
$invalidArgumentException->getMessage()
73+
);
74+
}
75+
}
76+
77+
public function testConstructWithClassNotHavingAConstructor(): void
78+
{
79+
try {
80+
new ObjectConstructorSchema([], \stdClass::class);
81+
82+
throw new \Exception('code should not be reached');
83+
} catch (\InvalidArgumentException $invalidArgumentException) {
84+
self::assertSame(
85+
'Class "'.\stdClass::class.'" does not have a __construct method',
86+
$invalidArgumentException->getMessage()
87+
);
88+
}
89+
}
90+
91+
public function testConstructWithMissingFieldSchema(): void
92+
{
93+
try {
94+
new ObjectConstructorSchema([], ObjectConstructorDemo::class);
95+
96+
throw new \Exception('code should not be reached');
97+
} catch (\InvalidArgumentException $invalidArgumentException) {
98+
self::assertSame(
99+
'Missing fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field1", "field2"',
100+
$invalidArgumentException->getMessage()
101+
);
102+
}
103+
}
104+
105+
public function testConstructWithAdditionalFieldSchema(): void
106+
{
107+
try {
108+
new ObjectConstructorSchema([
109+
'field1' => new StringSchema(),
110+
'field2' => new IntSchema(),
111+
'field3' => new FloatSchema(),
112+
'field4' => new FloatSchema(),
113+
], ObjectConstructorDemo::class)->optional(['field3']);
114+
115+
throw new \Exception('code should not be reached');
116+
} catch (\InvalidArgumentException $invalidArgumentException) {
117+
self::assertSame(
118+
'Additional fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field4"',
119+
$invalidArgumentException->getMessage()
120+
);
121+
}
122+
}
123+
124+
public function testSuccessWithAllParameters(): void
125+
{
126+
$input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159];
127+
128+
$schema = new ObjectConstructorSchema([
129+
'field1' => new StringSchema(),
130+
'field2' => new IntSchema(),
131+
'field3' => new FloatSchema(),
132+
], ObjectConstructorDemo::class)->optional(['field3']);
133+
134+
/** @var ObjectConstructorDemo $object */
135+
$object = $schema->parse($input);
136+
137+
self::assertInstanceOf(ObjectConstructorDemo::class, $object);
138+
139+
self::assertSame($input, $object->jsonSerialize());
140+
}
141+
142+
public function testSuccessWithAllParametersOptionalConsidered(): void
143+
{
144+
$input = ['field1' => 'test', 'field2' => 5];
145+
146+
$schema = new ObjectConstructorSchema([
147+
'field1' => new StringSchema(),
148+
'field2' => new IntSchema(),
149+
'field3' => new FloatSchema(),
150+
], ObjectConstructorDemo::class)->optional(['field3']);
151+
152+
/** @var ObjectConstructorDemo $object */
153+
$object = $schema->parse($input);
154+
155+
self::assertInstanceOf(ObjectConstructorDemo::class, $object);
156+
157+
self::assertSame([...$input, 'field3' => null], $object->jsonSerialize());
158+
}
159+
160+
public function testSuccessWithRequiredParameters(): void
161+
{
162+
$input = ['field1' => 'test', 'field2' => 5];
163+
164+
$schema = new ObjectConstructorSchema([
165+
'field1' => new StringSchema(),
166+
'field2' => new IntSchema(),
167+
], ObjectConstructorDemo::class)->optional(['field3']);
168+
169+
/** @var ObjectConstructorDemo $object */
170+
$object = $schema->parse($input);
171+
172+
self::assertInstanceOf(ObjectConstructorDemo::class, $object);
173+
174+
self::assertSame([...$input, 'field3' => null], $object->jsonSerialize());
175+
}
176+
177+
public function testFailedWithInvalidValue(): void
178+
{
179+
$input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test'];
180+
181+
$schema = new ObjectConstructorSchema([
182+
'field1' => new StringSchema(),
183+
'field2' => new IntSchema(),
184+
'field3' => new FloatSchema(),
185+
], ObjectConstructorDemo::class)->optional(['field3']);
186+
187+
try {
188+
$schema->parse($input);
189+
190+
throw new \Exception('code should not be reached');
191+
} catch (ErrorsException $errorsException) {
192+
self::assertSame([
193+
[
194+
'path' => 'field3',
195+
'error' => [
196+
'code' => 'float.type',
197+
'template' => 'Type should be "float", {{given}} given',
198+
'variables' => [
199+
'given' => 'string',
200+
],
201+
],
202+
],
203+
], $errorsException->errors->jsonSerialize());
204+
}
205+
}
206+
207+
public function testFailedWithInvalidValueNotCatchedByFieldSchema(): void
208+
{
209+
$input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test'];
210+
211+
$schema = new ObjectConstructorSchema([
212+
'field1' => new StringSchema(),
213+
'field2' => new IntSchema(),
214+
'field3' => new StringSchema(),
215+
], ObjectConstructorDemo::class)->optional(['field3']);
216+
217+
try {
218+
$schema->parse($input);
219+
220+
throw new \Exception('code should not be reached');
221+
} catch (ErrorsException $errorsException) {
222+
self::assertSame([
223+
[
224+
'path' => '',
225+
'error' => [
226+
'code' => 'object.parameterType',
227+
'template' => 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given',
228+
'variables' => [
229+
'index' => '3',
230+
'name' => '$field3',
231+
'type' => '?float',
232+
'given' => 'string',
233+
],
234+
],
235+
],
236+
], $errorsException->errors->jsonSerialize());
237+
}
238+
}
239+
240+
public function testFailedWithUnknownException(): void
241+
{
242+
$exception = new \Exception('unknown');
243+
244+
$input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159];
245+
246+
$schema = new ObjectConstructorSchema([
247+
'field1' => new StringSchema(),
248+
'field2' => new IntSchema(),
249+
'field3' => new FloatSchema()->postParse(static fn () => throw $exception),
250+
], ObjectConstructorDemo::class)->optional(['field3']);
251+
252+
try {
253+
$schema->parse($input);
254+
255+
throw new \Exception('code should not be reached');
256+
} catch (\Exception $e) {
257+
self::assertSame($exception, $e);
258+
}
259+
}
260+
261+
public function testFailedWithTypeErrorNotMatchingPattern(): void
262+
{
263+
$input = ['field1' => 'test'];
264+
265+
$schema = new ObjectConstructorSchema([
266+
'field1' => new StringSchema(),
267+
], ObjectConstructorThrowingTypeErrorDemo::class);
268+
269+
try {
270+
$schema->parse($input);
271+
272+
throw new \Exception('code should not be reached');
273+
} catch (\TypeError $e) {
274+
self::assertSame('some unrelated type error', $e->getMessage());
275+
}
276+
}
277+
}

0 commit comments

Comments
 (0)