Skip to content

Commit 6be5d44

Browse files
committed
feat(schema): add passthrough for undeclared keys
- Extract SchemaValidatorOptionsTrait for coerceAll, passthrough, and schema clone helper - Copy undeclared input keys/properties onto output after validation without overwriting outputKey targets - Add tests and document behavior
1 parent 2978550 commit 6be5d44

12 files changed

Lines changed: 205 additions & 28 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Core validation logic lives in `src/Lemmon/Validator/`. Tests in `tests/` follow
1919
- **Validators:** `isString`, `isInt`, `isFloat`, `isBool`, `isArray`, `isAssociative`, `isObject`
2020
- **Shared:** `NumericConstraintsTrait` (min, max, multipleOf, etc.); `PipelineType` enum; variant enums `IpVersion`, `Base64Variant`, `UuidVariant` for format methods
2121
- **String formats:** email, URL, UUID, IP, hostname, domain, time, base64, hex, regex, datetime, date
22-
- **Schema validation:** AssociativeValidator/ObjectValidator with nested error aggregation
22+
- **Schema validation:** AssociativeValidator/ObjectValidator with nested error aggregation; shared `SchemaValidatorOptionsTrait` (`coerceAll`, `passthrough`, schema clone helper); `passthrough()` keeps undeclared keys/properties (unvalidated); default output is schema keys only
2323
- **Logical combinators:** `Validator::allOf`, `anyOf`, `not`; instance `satisfiesAny`, `satisfiesAll`, `satisfiesNone`; `const()` for single allowed value; `enum()` for BackedEnum
2424
- **Behavior:** Optional by default (null allowed unless `required()`); form-safe coercion (empty string → null, not 0/false); pipeline order guaranteed; fail-fast per field; `satisfies()` accepts validators or callables with `(value, key, input)`; extend via `satisfies()`, not custom validators
2525

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `passthrough()` on `AssociativeValidator` and `ObjectValidator`: copy undeclared keys or public properties from the input to the validated output without validating them; declared schema fields are still validated; values already written from the schema (including `outputKey` targets) are not overwritten by passthrough
10+
711
## [0.14.0] - 2026-03-23
812

913
### Added

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ Focused on core validation primitives, consistent pipelines, and extensibility v
2424
## Future (Schema and Structure)
2525

2626
- [ ] Schema composition: `partial()`, `pick()`, `omit()`, `merge()`
27-
- [ ] Unknown key policies: `strict()` (reject undeclared keys) and `passthrough()` (preserve undeclared keys)
27+
- [x] `passthrough()` on `AssociativeValidator` / `ObjectValidator` (preserve undeclared keys)
28+
- [ ] `strict()` (reject undeclared keys)
2829
- [ ] Explicit key deny-listing: `forbidKeys(array $keys, ?string $message = null)`
2930
- [ ] Key validation: `patternProperties()`, `propertyNames()`
3031
- [ ] Cross-field dependencies: `dependencies()`

docs/api-reference/validator-factory.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ $user = $userValidator->validate([
135135

136136
**Returns:** `AssociativeValidator` instance with schema validation capabilities.
137137

138-
**See Also:** [Object & Schema Validation Guide](../guides/object-validation.md)
138+
**See also:** [Object & Schema Validation Guide](../guides/object-validation.md) (`coerceAll()`, `passthrough()`, and schema patterns).
139139

140140
---
141141

@@ -170,7 +170,7 @@ $validConfig = $configValidator->validate($config);
170170

171171
**Returns:** `ObjectValidator` instance with schema validation capabilities.
172172

173-
**See Also:** [Object & Schema Validation Guide](../guides/object-validation.md)
173+
**See also:** [Object & Schema Validation Guide](../guides/object-validation.md) (`coerceAll()`, `passthrough()`, and schema patterns).
174174

175175
---
176176

docs/getting-started/core-concepts.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Validator::isArray()->items($itemValidator) // With item validation
115115
```php
116116
Validator::isAssociative($schema) // Associative array with schema
117117
Validator::isObject($schema) // stdClass object with schema
118+
// Optional on both: ->coerceAll(), ->passthrough() (keep undeclared keys unvalidated)
118119
```
119120

120121
## Validation Flow

docs/guides/object-validation.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This guide covers validation of structured data using `AssociativeValidator` (fo
88
- [Object Validation](#object-validation)
99
- [Schema Definition](#schema-definition)
1010
- [Output Key Remapping](#output-key-remapping)
11+
- [Passthrough (undeclared keys)](#passthrough-undeclared-keys)
1112
- [Type Coercion](#type-coercion)
1213
- [Advanced Features](#advanced-features)
1314
- [Common Patterns](#common-patterns)
@@ -156,7 +157,7 @@ Works with both `Validator::isAssociative()` and `Validator::isObject()`.
156157

157158
### Field Inclusion Behavior
158159

159-
Schema validators only include fields in the result that were either:
160+
**By default**, schema validators only include fields in the result that were either:
160161

161162
1. **Provided in the input data**, or
162163
2. **Have default values applied**
@@ -190,10 +191,41 @@ $result = $schema->validate($input);
190191

191192
This behavior ensures that:
192193

193-
- **Results accurately reflect the validated data** without unexpected properties
194+
- **Results accurately reflect the validated data** without unexpected properties (unless you opt into `passthrough()` below)
194195
- **Default values are consistently applied** when fields are missing
195196
- **Required field validation still works** (missing required fields cause validation to fail)
196197

198+
### Passthrough (undeclared keys)
199+
200+
Call `passthrough()` on a schema-backed `Validator::isAssociative()` or `Validator::isObject()` to **copy input keys that are not declared in the schema** onto the validated output **without validating them**. Declared fields are still run through their validators. Values already written from the schema (including keys produced by `outputKey()`) are **not** overwritten by passthrough.
201+
202+
Passthrough runs only after all schema fields validate successfully. Copied values are shallow: nested arrays or objects are not recursively validated.
203+
204+
```php
205+
$schema = Validator::isAssociative([
206+
'name' => Validator::isString()->required(),
207+
])->passthrough();
208+
209+
$input = [
210+
'name' => 'Ann',
211+
'metadata' => ['any' => 'structure'],
212+
'version' => 1,
213+
];
214+
215+
$result = $schema->validate($input);
216+
// Result includes name (validated) plus metadata and version (copied as-is)
217+
```
218+
219+
With an **empty schema**, `passthrough()` keeps the entire map (still subject to array/object type rules and coercion on the container itself). That pattern is useful for optional opaque payloads such as API metadata:
220+
221+
```php
222+
$result = Validator::isAssociative([])->passthrough()
223+
->validate(['foo' => 1, 'nested' => ['x' => true]]);
224+
// $result is the same associative shape, unvalidated
225+
```
226+
227+
For `ObjectValidator`, only **public** properties on the input object are considered for passthrough (same visibility as `get_object_vars()`).
228+
197229
### Nested Schemas
198230

199231
```php

llms.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,11 @@ _Schema validation_
148148
```php
149149
// Constructor accepts array<string, FieldValidator>
150150
coerceAll(): static // Recursively enables coercion on schema fields
151+
passthrough(): static // Copy undeclared keys/properties from input to output without validating them; schema fields still validated; does not overwrite keys already set from the schema (including outputKey targets)
151152
```
152153

154+
By default, outputs only schema-defined keys (plus defaults). With `passthrough()`, any input key not in the schema is copied through unchanged (shallow); nested structures are not recursively validated. For `ObjectValidator`, only **public** properties from the input object are considered (via `get_object_vars()`).
155+
153156
### BoolValidator
154157

155158
```php

src/Lemmon/Validator/AssociativeValidator.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
class AssociativeValidator extends FieldValidator
88
{
9-
private bool $coerceAll = false;
9+
use SchemaValidatorOptionsTrait;
1010

1111
/**
1212
* @param array<string, FieldValidator> $schema
@@ -23,12 +23,6 @@ protected function getValidatorType(): string
2323
return 'associative_array';
2424
}
2525

26-
public function coerceAll(): self
27-
{
28-
$this->coerceAll = true;
29-
return $this;
30-
}
31-
3226
/**
3327
* @inheritDoc
3428
*/
@@ -93,15 +87,21 @@ protected function validateType(mixed $value, string $key): mixed
9387
throw new ValidationException($errors);
9488
}
9589

90+
if ($this->passthrough) {
91+
foreach ($value as $inputKey => $rawValue) {
92+
if (\array_key_exists($inputKey, $this->schema) || \array_key_exists($inputKey, $data)) {
93+
continue;
94+
}
95+
$data[$inputKey] = $rawValue;
96+
}
97+
}
98+
9699
return $data;
97100
}
98101

99102
public function __clone()
100103
{
101104
parent::__clone();
102-
103-
foreach ($this->schema as $fieldKey => $validator) {
104-
$this->schema[$fieldKey] = $validator->clone();
105-
}
105+
$this->cloneSchemaFieldValidators();
106106
}
107107
}

src/Lemmon/Validator/ObjectValidator.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
class ObjectValidator extends FieldValidator
88
{
9-
private bool $coerceAll = false;
9+
use SchemaValidatorOptionsTrait;
1010

1111
/**
1212
* @param array<string, FieldValidator> $schema
@@ -23,12 +23,6 @@ protected function getValidatorType(): string
2323
return 'object';
2424
}
2525

26-
public function coerceAll(): self
27-
{
28-
$this->coerceAll = true;
29-
return $this;
30-
}
31-
3226
/**
3327
* @inheritDoc
3428
*/
@@ -93,15 +87,21 @@ protected function validateType(mixed $value, string $key): mixed
9387
throw new ValidationException($errors);
9488
}
9589

90+
if ($this->passthrough) {
91+
foreach (\get_object_vars($value) as $inputKey => $rawValue) {
92+
if (\array_key_exists($inputKey, $this->schema) || \property_exists($data, $inputKey)) {
93+
continue;
94+
}
95+
$data->{$inputKey} = $rawValue;
96+
}
97+
}
98+
9699
return $data;
97100
}
98101

99102
public function __clone()
100103
{
101104
parent::__clone();
102-
103-
foreach ($this->schema as $fieldKey => $validator) {
104-
$this->schema[$fieldKey] = $validator->clone();
105-
}
105+
$this->cloneSchemaFieldValidators();
106106
}
107107
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Lemmon\Validator;
6+
7+
/**
8+
* Shared options and helpers for {@see AssociativeValidator} and {@see ObjectValidator}.
9+
*
10+
* Expects the using class to define a private `array<string, FieldValidator> $schema`.
11+
*/
12+
trait SchemaValidatorOptionsTrait
13+
{
14+
private bool $coerceAll = false;
15+
16+
private bool $passthrough = false;
17+
18+
public function coerceAll(): self
19+
{
20+
$this->coerceAll = true;
21+
return $this;
22+
}
23+
24+
/**
25+
* Preserve input members that are not declared in the schema, without validating them.
26+
*
27+
* Schema fields are still validated. Output members already set from the schema (including
28+
* via {@see FieldValidator::outputKey()}) are not overwritten by passthrough values.
29+
*/
30+
public function passthrough(): self
31+
{
32+
$this->passthrough = true;
33+
return $this;
34+
}
35+
36+
/**
37+
* Deep-clone validators in the schema; call from {@see __clone()} after `parent::__clone()`.
38+
*/
39+
protected function cloneSchemaFieldValidators(): void
40+
{
41+
foreach ($this->schema as $fieldKey => $validator) {
42+
$this->schema[$fieldKey] = $validator->clone();
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)