Skip to content

Commit ebe2756

Browse files
committed
optimize schema
1 parent 31b4505 commit ebe2756

7 files changed

Lines changed: 466 additions & 57 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ $user = $userSchema->parse([
7979
| `array()` | Arrays with item validation | [ArraySchema](doc/Schema/ArraySchema.md) |
8080
| `assoc()` | Associative arrays with field schemas | [AssocSchema](doc/Schema/AssocSchema.md) |
8181
| `object()` | Objects/DTOs with field schemas | [ObjectSchema](doc/Schema/ObjectSchema.md) |
82+
| `objectConstructor()` | Objects/DTOs with field schemas | [ObjectSchemaConstructor](doc/Schema/ObjectSchemaConstructor.md) |
8283
| `tuple()` | Fixed-length arrays with positional types | [TupleSchema](doc/Schema/TupleSchema.md) |
8384
| `record()` | Key-value maps with uniform value types | [RecordSchema](doc/Schema/RecordSchema.md) |
8485

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# ObjectConstructorSchema
2+
3+
The `ObjectConstructorSchema` parses an associative array (or compatible input) and constructs an instance of a given class by passing validated fields to its constructor.
4+
5+
## Basic Usage
6+
7+
```php
8+
<?php
9+
10+
use Chubbyphp\Parsing\Parser;
11+
12+
class User
13+
{
14+
public function __construct(
15+
public readonly string $name,
16+
public readonly int $age
17+
) {}
18+
}
19+
20+
$p = new Parser();
21+
22+
$schema = $p->objectConstructor([
23+
'name' => $p->string(),
24+
'age' => $p->int(),
25+
], User::class);
26+
27+
$user = $schema->parse(['name' => 'John', 'age' => 30]);
28+
// Returns: User instance with populated properties
29+
```
30+
31+
## Constructor Validation
32+
33+
The schema validates that:
34+
1. All non-optional constructor parameters have a corresponding schema definition.
35+
2. No extra schemas are provided that don't match a constructor parameter.
36+
3. The types of the parsed values match the constructor parameter types (handling `TypeError` automatically).
37+
38+
## Supported Input Types
39+
40+
The `ObjectConstructorSchema` accepts multiple input formats, which are converted to an associative array before processing:
41+
42+
- **Arrays** - Standard associative arrays
43+
- **stdClass** - Anonymous objects
44+
- **Traversable** - Objects implementing `\Traversable`
45+
- **JsonSerializable** - Objects implementing `\JsonSerializable`
46+
47+
## Validations
48+
49+
### Strict Mode
50+
51+
By default, unknown fields in the input are silently ignored. Use `strict()` to reject unknown fields:
52+
53+
```php
54+
$schema = $p->objectConstructor([
55+
'name' => $p->string()
56+
], User::class)->strict();
57+
58+
$schema->parse(['name' => 'John']); // OK
59+
$schema->parse(['name' => 'John', 'extra' => 1]); // Throws error: object.unknownField
60+
```
61+
62+
### Strict with Exceptions
63+
64+
Allow specific unknown fields to be ignored while rejecting others:
65+
66+
```php
67+
$schema = $p->objectConstructor([
68+
'name' => $p->string()
69+
], User::class)->strict(['_id', '_rev']);
70+
71+
$schema->parse(['name' => 'John', '_id' => '123']); // OK, _id ignored
72+
$schema->parse(['name' => 'John', 'unknown' => 'val']); // Throws error: object.unknownField
73+
```
74+
75+
### Optional Fields
76+
77+
Make certain fields optional in the input. If an optional field is missing from the input, it won't be passed to the constructor (so the constructor parameter must be optional).
78+
79+
```php
80+
class User
81+
{
82+
public function __construct(
83+
public readonly string $name,
84+
public readonly string $nickname = ''
85+
) {}
86+
}
87+
88+
$schema = $p->objectConstructor([
89+
'name' => $p->string(),
90+
'nickname' => $p->string(),
91+
], User::class)->optional(['nickname']);
92+
93+
$schema->parse(['name' => 'John']);
94+
// Returns: User(name: 'John', nickname: '') - using default value from constructor
95+
```
96+
97+
## Schema Utilities
98+
99+
### Get Field Schema
100+
101+
Retrieve the schema for a specific field:
102+
103+
```php
104+
$nameSchema = $schema->getFieldSchema('name'); // Returns StringSchema
105+
```
106+
107+
### Extend Schema
108+
109+
Get all field schemas to extend or compose:
110+
111+
```php
112+
$baseSchema = $p->objectConstructor([
113+
'id' => $p->int(),
114+
], BaseEntity::class);
115+
116+
$userSchema = $p->objectConstructor([
117+
...$baseSchema->getFieldToSchema(),
118+
'name' => $p->string(),
119+
], User::class);
120+
```
121+
122+
## Error Codes
123+
124+
| Code | Description |
125+
|------|-------------|
126+
| `object.type` | Value is not a valid object type (array, stdClass, Traversable) |
127+
| `object.unknownField` | Unknown field found in strict mode |
128+
| `object.parameterType` | Constructor parameter type mismatch (e.g., int passed to string parameter) |
129+
130+
Field-level errors (like validation failures within `name` or `age` schemas) include the field name in the error path.

phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
parameters:
2+
ignoreErrors:
3+
-
4+
message: '#Dead catch - ReflectionException is never thrown in the try block#'

src/Parser.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Chubbyphp\Parsing\Schema\IntSchema;
1616
use Chubbyphp\Parsing\Schema\LazySchema;
1717
use Chubbyphp\Parsing\Schema\LiteralSchema;
18+
use Chubbyphp\Parsing\Schema\ObjectConstructorSchema;
1819
use Chubbyphp\Parsing\Schema\ObjectSchema;
1920
use Chubbyphp\Parsing\Schema\ObjectSchemaInterface;
2021
use Chubbyphp\Parsing\Schema\RecordSchema;
@@ -106,6 +107,15 @@ public function object(array $fieldNameToSchema, string $classname = \stdClass::
106107
return new ObjectSchema($fieldNameToSchema, $classname);
107108
}
108109

110+
/**
111+
* @param array<string, SchemaInterface> $fieldNameToSchema
112+
* @param class-string $classname
113+
*/
114+
public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema
115+
{
116+
return new ObjectConstructorSchema($fieldNameToSchema, $classname);
117+
}
118+
109119
public function record(SchemaInterface $fieldSchema): RecordSchema
110120
{
111121
return new RecordSchema($fieldSchema);

src/ParserInterface.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Chubbyphp\Parsing\Schema\FloatSchema;
1515
use Chubbyphp\Parsing\Schema\IntSchema;
1616
use Chubbyphp\Parsing\Schema\LiteralSchema;
17+
use Chubbyphp\Parsing\Schema\ObjectConstructorSchema;
1718
use Chubbyphp\Parsing\Schema\ObjectSchema;
1819
use Chubbyphp\Parsing\Schema\ObjectSchemaInterface;
1920
use Chubbyphp\Parsing\Schema\RecordSchema;
@@ -23,8 +24,9 @@
2324
use Chubbyphp\Parsing\Schema\UnionSchema;
2425

2526
/**
26-
* @method AssocSchema assoc(array<string, SchemaInterface> $fieldNameToSchema)
27-
* @method ConstSchema const(bool|float|int|string $const)
27+
* @method AssocSchema assoc(array<string, SchemaInterface> $fieldNameToSchema)
28+
* @method ConstSchema const(bool|float|int|string $const)
29+
* @method ObjectConstructorSchema objectConstructor(array<string, SchemaInterface> $fieldNameToSchema, class-string $classname)
2830
*/
2931
interface ParserInterface
3032
{
@@ -71,6 +73,12 @@ public function literal(bool|float|int|string $literal): LiteralSchema;
7173
*/
7274
public function object(array $fieldNameToSchema, string $classname = \stdClass::class): ObjectSchema;
7375

76+
// /**
77+
// * @param array<string, SchemaInterface> $fieldNameToSchema
78+
// * @param class-string $classname
79+
// */
80+
// public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema;
81+
7482
public function record(SchemaInterface $fieldSchema): RecordSchema;
7583

7684
public function string(): StringSchema;

src/Schema/ObjectConstructorSchema.php

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ final class ObjectConstructorSchema extends AbstractObjectSchema implements Obje
1414
public const string ERROR_UNKNOWN_FIELD_CODE = 'object.unknownField';
1515

1616
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';
17+
public const string ERROR_PARAMETER_TYPE_TEMPLATE = 'Parameter {{name}} should be of {{type}}, {{given}} given';
1818

19-
private readonly string $typeErrorPattern;
19+
/**
20+
* @var array<string, null|\ReflectionType>
21+
*/
22+
private readonly array $fieldToType;
2023

2124
/**
2225
* @param array<mixed, mixed> $fieldToSchema
@@ -36,33 +39,43 @@ public function __construct(array $fieldToSchema, private string $classname)
3639
throw new \InvalidArgumentException('Class "'.$classname.'" does not have a __construct method');
3740
}
3841

39-
$parameterFieldToSchema = [];
40-
4142
/** @var list<string> $missingFieldToSchema */
4243
$missingFieldToSchema = [];
44+
45+
/** @var array<string, SchemaInterface> $sortedFieldToSchema */
46+
$sortedFieldToSchema = [];
47+
48+
/** @var array<string, null|\ReflectionType> $fieldToType */
49+
$fieldToType = [];
4350
foreach ($constructorReflectionMethod->getParameters() as $parameterReflection) {
4451
$name = $parameterReflection->getName();
52+
$fieldToType[$name] = $parameterReflection->getType();
4553

4654
if (isset($fieldToSchema[$name])) {
47-
$parameterFieldToSchema[$name] = $fieldToSchema[$name];
48-
55+
$sortedFieldToSchema[$name] = $fieldToSchema[$name];
4956
unset($fieldToSchema[$name]);
5057
} elseif (!$parameterReflection->isOptional()) {
5158
$missingFieldToSchema[] = $name;
5259
}
5360
}
5461

5562
if ([] !== $missingFieldToSchema) {
56-
throw new \InvalidArgumentException('Missing fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', $missingFieldToSchema).'"');
63+
throw new \InvalidArgumentException(
64+
'Missing fieldToSchema for "'.$classname.'" __construct parameters: "'
65+
.implode('", "', $missingFieldToSchema).'"'
66+
);
5767
}
5868

5969
if ([] !== $fieldToSchema) {
60-
throw new \InvalidArgumentException('Additional fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', array_keys($fieldToSchema)).'"');
70+
throw new \InvalidArgumentException(
71+
'Additional fieldToSchema for "'.$classname.'" __construct parameters: "'
72+
.implode('", "', array_keys($fieldToSchema)).'"'
73+
);
6174
}
6275

63-
$this->typeErrorPattern = \sprintf('/%s::__construct\(\): Argument #(\d+) \(([^)]+)\) must be of type ([^ ]+), ([^ ]+) given/', preg_quote($this->classname, '/'));
76+
$this->fieldToType = $fieldToType;
6477

65-
parent::__construct($parameterFieldToSchema);
78+
parent::__construct($sortedFieldToSchema);
6679
}
6780

6881
/**
@@ -78,28 +91,105 @@ protected function parseFields(array $input, Errors $childrenErrors): ?object
7891
continue;
7992
}
8093

81-
$parameters[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null);
94+
$fieldValue = $fieldSchema->parse($input[$fieldName] ?? null);
95+
96+
$fieldType = $this->fieldToType[$fieldName];
97+
98+
if (null !== $fieldType && !$this->matchesType($fieldValue, $fieldType)) {
99+
throw new ErrorsException(new Error(
100+
self::ERROR_PARAMETER_TYPE_CODE,
101+
self::ERROR_PARAMETER_TYPE_TEMPLATE,
102+
[
103+
'name' => $fieldName,
104+
'type' => $this->typeToString($fieldType),
105+
'given' => $this->getDataType($fieldValue),
106+
]
107+
));
108+
}
109+
110+
$parameters[$fieldName] = $fieldValue;
82111
} catch (ErrorsException $e) {
83112
$childrenErrors->add($e->errors, $fieldName);
84113
}
85114
}
86115

87-
try {
116+
if (!$childrenErrors->has()) {
88117
return new ($this->classname)(...$parameters);
89-
} catch (\TypeError $e) {
90-
$matches = [];
118+
}
91119

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-
);
120+
return null;
121+
}
122+
123+
private function matchesType(mixed $value, \ReflectionType $type): bool
124+
{
125+
if ($type instanceof \ReflectionIntersectionType) {
126+
foreach ($type->getTypes() as $innerType) {
127+
if (!$this->matchesType($value, $innerType)) {
128+
return false;
129+
}
130+
}
131+
132+
return true;
133+
}
134+
135+
if ($type instanceof \ReflectionUnionType) {
136+
foreach ($type->getTypes() as $innerType) {
137+
if ($this->matchesType($value, $innerType)) {
138+
return true;
139+
}
100140
}
101141

102-
throw $e;
142+
return false;
103143
}
144+
145+
$typeName = $this->resolveNamedTypeName($type);
146+
147+
if (null === $value) {
148+
return $type->allowsNull();
149+
}
150+
151+
return match ($typeName) {
152+
'int', 'integer' => \is_int($value),
153+
'float', 'double' => \is_float($value) || \is_int($value),
154+
'string' => \is_string($value),
155+
'bool', 'boolean' => \is_bool($value),
156+
'array' => \is_array($value),
157+
'object' => \is_object($value),
158+
'null' => false,
159+
'mixed' => true,
160+
'iterable' => is_iterable($value),
161+
'callable' => \is_callable($value),
162+
'true' => true === $value,
163+
'false' => false === $value,
164+
default => $value instanceof $typeName,
165+
};
166+
}
167+
168+
private function typeToString(\ReflectionType $type): string
169+
{
170+
if ($type instanceof \ReflectionIntersectionType) {
171+
return implode('&', array_map($this->typeToString(...), $type->getTypes()));
172+
}
173+
174+
if ($type instanceof \ReflectionUnionType) {
175+
return implode('|', array_map($this->typeToString(...), $type->getTypes()));
176+
}
177+
178+
$name = $this->resolveNamedTypeName($type);
179+
180+
if ($type->allowsNull() && 'mixed' !== $name && 'null' !== $name) {
181+
return $name.'|null';
182+
}
183+
184+
return $name;
185+
}
186+
187+
private function resolveNamedTypeName(\ReflectionNamedType $type): string
188+
{
189+
return match ($type->getName()) {
190+
'self', 'static' => $this->classname,
191+
'parent' => get_parent_class($this->classname),
192+
default => $type->getName(),
193+
};
104194
}
105195
}

0 commit comments

Comments
 (0)