Skip to content

Commit 9465acd

Browse files
committed
object-constructor-schema
1 parent 6eafd3e commit 9465acd

2 files changed

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

0 commit comments

Comments
 (0)