From 5afec459a9b4a70613c300111f372a679c55821b Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 17 Aug 2026 20:10:30 +0200 Subject: [PATCH 01/21] opened 1.4-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fd48db3..6535ac9 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.3-dev" + "dev-master": "1.4-dev" } }, "config": { From 3da3bc7da5c9aa361c7b0dc68857825b35187fa5 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:07:04 +0200 Subject: [PATCH 02/21] Base: multiple before() handlers are chained instead of silently replaced --- src/Schema/Elements/Base.php | 12 ++++++------ tests/Schema/Expect.before.phpt | 9 +++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index c4e3dd6..0cd1e19 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -21,8 +21,8 @@ trait Base private bool $required = false; private mixed $default = null; - /** @var ?\Closure(mixed): mixed */ - private ?\Closure $before = null; + /** @var list<\Closure(mixed): mixed> */ + private array $before = []; /** @var list<\Closure(mixed, Context): mixed> */ private array $transforms = []; @@ -44,12 +44,12 @@ public function required(bool $state = true): self /** - * Sets a pre-normalization callback applied to the raw input value before any validation. + * Adds a pre-normalization callback applied to the raw input value before any validation. * @param callable(mixed): mixed $handler */ public function before(callable $handler): self { - $this->before = $handler(...); + $this->before[] = $handler(...); return $this; } @@ -121,8 +121,8 @@ public function completeDefault(Context $context): mixed public function doNormalize(mixed $value, Context $context): mixed { - if ($this->before) { - $value = ($this->before)($value); + foreach ($this->before as $handler) { + $value = $handler($value); } return $value; diff --git a/tests/Schema/Expect.before.phpt b/tests/Schema/Expect.before.phpt index fda4d59..e83bf28 100644 --- a/tests/Schema/Expect.before.phpt +++ b/tests/Schema/Expect.before.phpt @@ -19,6 +19,15 @@ test('', function () { }); +test('multiple handlers run in registration order', function () { + $schema = Expect::string() + ->before(fn($val) => $val . 'a') + ->before(fn($val) => $val . 'b'); + + Assert::same('xab', $schema->normalize('x', new Context)); +}); + + test('structure property', function () { $schema = Expect::structure([ 'key' => Expect::string()->before('strrev'), From e32d7b10fcffeb8d9fc2152ff7006c03aabdfefb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 17 Aug 2026 21:39:31 +0200 Subject: [PATCH 03/21] added description() to schema elements --- src/Schema/Elements/Base.php | 11 +++++++++++ tests/Schema/Expect.description.phpt | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/Schema/Expect.description.phpt diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index 0cd1e19..7bb70d1 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -27,6 +27,7 @@ trait Base /** @var list<\Closure(mixed, Context): mixed> */ private array $transforms = []; private ?string $deprecated = null; + private ?string $description = null; public function default(mixed $value): self @@ -105,6 +106,16 @@ public function deprecated(string $message = 'The item %path% is deprecated.'): } + /** + * Sets a human-readable description of the item; it does not affect validation. + */ + public function description(string $description): self + { + $this->description = $description; + return $this; + } + + public function completeDefault(Context $context): mixed { if ($this->required) { diff --git a/tests/Schema/Expect.description.phpt b/tests/Schema/Expect.description.phpt new file mode 100644 index 0000000..bb43535 --- /dev/null +++ b/tests/Schema/Expect.description.phpt @@ -0,0 +1,21 @@ + Expect::string()->description('Full name')->required(), + ]); + + Assert::equal((object) ['name' => 'John'], (new Processor)->process($schema, ['name' => 'John'])); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, []); + }, ["The mandatory item 'name' is missing."]); +}); From 79f72f19cb57b98773e0456eeaf0495fdfe7e7b3 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 17 Aug 2026 21:39:54 +0200 Subject: [PATCH 04/21] added schema inspection (describe()) and JSON Schema export Type, Structure and AnyOf report what they accept as a plain array with a closed Kind vocabulary; child schemas are reported as they are. TypeExpression is the single translator of the Expect::type() language into that array and follows Validators::is() exactly (an unknown name is a class, 'int[]' is any iterable of ints, legacy validator names are Other). JsonSchema::export() is a pure function over describe() and refuses PHP-only types instead of emitting a schema the Processor would reject. Everything is @internal for now so the vocabulary can still change. --- docs/internals.md | 35 +++++ src/Schema/Elements/AnyOf.php | 36 +++++ src/Schema/Elements/Base.php | 10 ++ src/Schema/Elements/Structure.php | 17 +++ src/Schema/Elements/Type.php | 39 +++++- src/Schema/JsonSchema.php | 212 ++++++++++++++++++++++++++++++ src/Schema/Kind.php | 33 +++++ src/Schema/TypeExpression.php | 114 ++++++++++++++++ tests/Schema/Expect.describe.phpt | 153 +++++++++++++++++++++ tests/Schema/JsonSchema.phpt | 211 +++++++++++++++++++++++++++++ tests/Schema/TypeExpression.phpt | 145 ++++++++++++++++++++ 11 files changed, 1004 insertions(+), 1 deletion(-) create mode 100644 src/Schema/JsonSchema.php create mode 100644 src/Schema/Kind.php create mode 100644 src/Schema/TypeExpression.php create mode 100644 tests/Schema/Expect.describe.phpt create mode 100644 tests/Schema/JsonSchema.phpt create mode 100644 tests/Schema/TypeExpression.phpt diff --git a/docs/internals.md b/docs/internals.md index a425b15..20756be 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -192,6 +192,40 @@ The result is a `Structure` with `castTo($class)` **stacked after** the constructor's built-in `castTo('object')`, so a completed value travels array → `stdClass` → instance through the cast fork above. +## Inspection: `describe()`, `TypeExpression` and the JSON Schema export (`@internal`) + +`Type`, `Structure` and `AnyOf` report what they accept as a **plain array**: +`describe()` returns `'kind' => Kind` (a closed vocabulary), `required`, +`description` and the keys of the kind (`min`, `max`, `pattern`, `format`, +`items`, `keys`, `shape`, `otherItems`, `values`, `variants`, `type`, plus +`nullable`/`dynamic` on `Type` and `AnyOf`). **Child schemas are reported as they +are, not expanded**; only the variants of a `Type` union and the items of `'int[]'` +are arrays, because there is no element behind them. The array is exactly what +`JsonSchema::export()` needs and nothing more; there is no descriptor class. + +**`TypeExpression::parse()` is the single translator of the `Expect::type()` +string language** (`|`, `?`, `[]`, `name:range`, `pattern:regex`) into that array. +It follows what `Validators::is()` accepts, not what the author probably meant: +`'int[]'` is `Kind::Iterable` of ints (any iterable, keys unchecked), +`'number'`/`'scalar'` expand to unions, `'?x'`/`'null|x'` set `nullable`, +`DynamicParameter` sets `dynamic`, **an unknown name is a class name** +(`Kind::Instance`, decided by exclusion, never by `class_exists`), and only the +fixed list of legacy validator names (`numeric`, `file`, `url`, ...) plus +intersections `A&B` become `Kind::Other` with the raw expression under `type`. +That table is also the migration table for the day the string notation is reduced +to BC sugar. `Type::describe()` = parse, then **narrow every variant** the +element's own `min()`/`max()` (intersected with a range from the expression), +`pattern()` (string variants only) and `items()` (array-like variants only) apply +to. `null` and `DynamicParameter` are flags, never variants, and `min`/`max` keep +the type-relative meaning of `validateRange`, so they live on the variants. + +`JsonSchema::export()` emits shape only (`description` yes; defaults, casts and +transforms no), anchors `pattern` as `^(?:…)$`, maps `Kind::Array` to a JSON +object unless the key type is `Kind::Int`, narrows `Kind::Iterable` to a JSON +array, and **throws `NotSupportedException` for `Instance`, `Object`, `Callable` +and `Other`** rather than emitting a schema the `Processor` would then reject. +Everything here is `@internal` so the vocabulary can still change. + ## Navigation map | Concern | Where | @@ -208,3 +242,4 @@ array → `stdClass` → instance through the cast fork above. | Error message rendering | `Message::toString`, `Message::*` code constants | | Key schemas, `isKey` | `Type::normalize`/`validateItems`, `Context::isKey` | | Object-to-schema mapping | `Expect::from`, `Helpers::getPropertyType` | +| Inspection, JSON Schema export | `Elements/*::describe`, `Kind`, `TypeExpression::parse`, `JsonSchema::export` | diff --git a/src/Schema/Elements/AnyOf.php b/src/Schema/Elements/AnyOf.php index 582cf13..8fe5406 100644 --- a/src/Schema/Elements/AnyOf.php +++ b/src/Schema/Elements/AnyOf.php @@ -10,6 +10,7 @@ use Nette; use Nette\Schema\Context; use Nette\Schema\Helpers; +use Nette\Schema\Kind; use Nette\Schema\Schema; use function array_merge, array_unique, implode, is_array; @@ -65,6 +66,41 @@ public function dynamic(): self } + /********************* inspection ****************d*g**/ + + + /** + * Scalar variants are reported as 'values', schema variants as 'variants'; scalars alone make an Enum, + * anything with a schema variant a Union. + * @return array + */ + public function describe(): array + { + $values = $variants = []; + $nullable = false; + foreach ($this->set as $item) { + if ($item === null) { + $nullable = true; + } elseif (!$item instanceof Schema) { + $values[] = $item; + } elseif (!($item instanceof Type && ($d = $item->describe())['kind'] === Kind::Any && $d['dynamic'])) { + $variants[] = $item; // the variant dynamic() adds accepts a DynamicParameter only, that is not a shape + } + } + + return [ + 'kind' => match (true) { + (bool) $variants => Kind::Union, + (bool) $values => Kind::Enum, + default => $nullable ? Kind::Null : Kind::Any, + }, + 'values' => $values, + 'variants' => $variants, + 'nullable' => $nullable, + ] + $this->describeBase(); + } + + /********************* processing ****************d*g**/ diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index 7bb70d1..918ca98 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -116,6 +116,16 @@ public function description(string $description): self } + /** + * The metadata every element reports from describe(); the element adds its own keys. + * @return array{required: bool, description: ?string} + */ + protected function describeBase(): array + { + return ['required' => $this->required, 'description' => $this->description]; + } + + public function completeDefault(Context $context): mixed { if ($this->required) { diff --git a/src/Schema/Elements/Structure.php b/src/Schema/Elements/Structure.php index a6cf02d..838f9c9 100644 --- a/src/Schema/Elements/Structure.php +++ b/src/Schema/Elements/Structure.php @@ -10,6 +10,7 @@ use Nette; use Nette\Schema\Context; use Nette\Schema\Helpers; +use Nette\Schema\Kind; use Nette\Schema\Schema; use function array_diff_key, array_fill_keys, array_key_exists, array_keys, array_map, array_merge, array_pop, array_values, is_array, is_object, strval; @@ -100,6 +101,22 @@ public function getShape(): array } + /********************* inspection ****************d*g**/ + + + /** @return array */ + public function describe(): array + { + return [ + 'kind' => Kind::Structure, + 'shape' => $this->items, + 'otherItems' => $this->otherItems, + 'min' => $this->range[0], + 'max' => $this->range[1], + ] + $this->describeBase(); + } + + /********************* processing ****************d*g**/ diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index 880f4bb..8959188 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -10,9 +10,11 @@ use Nette\Schema\Context; use Nette\Schema\DynamicParameter; use Nette\Schema\Helpers; +use Nette\Schema\Kind; use Nette\Schema\Schema; +use Nette\Schema\TypeExpression; use Nette\Utils\Validators; -use function array_key_exists, array_pop, implode, is_array, str_replace, strpos; +use function array_key_exists, array_map, array_pop, implode, in_array, is_array, max, min, str_replace, strpos; final class Type implements Schema @@ -106,6 +108,41 @@ public function pattern(?string $pattern): self } + /********************* inspection ****************d*g**/ + + + /** + * Reports what the type accepts as a plain array (see TypeExpression::parse()); min(), max(), pattern() + * and items() narrow every variant they apply to, child schemas are reported as they are. + * @return array + */ + public function describe(): array + { + $narrow = function (array $item) use (&$narrow): array { + if ($item['kind'] === Kind::Union) { + $item['variants'] = array_map($narrow, $item['variants']); + return $item; + } + + $arrayLike = in_array($item['kind'], [Kind::Array, Kind::List, Kind::Iterable], strict: true); + if ($arrayLike || in_array($item['kind'], [Kind::Int, Kind::Float, Kind::String], strict: true)) { + $item['min'] = $this->range[0] === null ? $item['min'] : max($this->range[0], $item['min'] ?? -INF); + $item['max'] = $this->range[1] === null ? $item['max'] : min($this->range[1], $item['max'] ?? INF); + } + if ($item['kind'] === Kind::String && $this->pattern !== null) { + $item['pattern'] = $this->pattern; + } + if ($arrayLike && $this->itemsValue) { + $item['items'] = $this->itemsValue; + $item['keys'] = $this->itemsKey; + } + return $item; + }; + + return $narrow(TypeExpression::parse($this->type)) + $this->describeBase(); + } + + /********************* processing ****************d*g**/ diff --git a/src/Schema/JsonSchema.php b/src/Schema/JsonSchema.php new file mode 100644 index 0000000..c899e1d --- /dev/null +++ b/src/Schema/JsonSchema.php @@ -0,0 +1,212 @@ +|\stdClass + * @throws Nette\NotSupportedException when the schema contains a class type or another PHP-only type + */ + public static function export(Schema $schema): array|\stdClass + { + return self::build($schema); + } + + + /** + * @param Schema|array $schema a schema, or a description as describe() returns it + * @return array|\stdClass + */ + private static function build(Schema|array $schema): array|\stdClass + { + $item = self::describe($schema); + $kind = $item['kind']; + if (!$kind instanceof Kind) { + throw new Nette\InvalidStateException('describe() must report a Kind.'); + } + + $res = match ($kind) { + Kind::Any => [], + Kind::Null => ['type' => 'null'], + Kind::Bool => ['type' => 'boolean'], + Kind::Int => ['type' => 'integer'] + self::range($item, 'minimum', 'maximum'), + Kind::Float => ['type' => 'number'] + self::range($item, 'minimum', 'maximum'), + Kind::String => self::buildString($item), + // an iterable of items is narrowed to a JSON array, which PHP accepts too + Kind::List, Kind::Iterable => ['type' => 'array', 'items' => self::buildOrAny($item['items'])] + self::range($item, 'minItems', 'maxItems'), + Kind::Array => self::buildArray($item), + Kind::Structure => self::buildStructure($item), + Kind::Enum => self::buildEnum($item['values']), + Kind::Union => self::buildUnion($item), + Kind::Instance, Kind::Other, Kind::Object, Kind::Callable => throw new Nette\NotSupportedException("Type '" . ($item['type'] ?? strtolower($kind->name)) . "' cannot be expressed in JSON Schema."), + }; + + if ($item['nullable'] ?? false) { + if (isset($res['enum'])) { + $res['enum'][] = null; + } + if (isset($res['type'])) { + $res['type'] = [$res['type'], 'null']; + } elseif (isset($res['anyOf'])) { + $res['anyOf'][] = ['type' => 'null']; + } + } + + if (($item['description'] ?? null) !== null) { + $res['description'] = $item['description']; + } + + return $res ?: new \stdClass; + } + + + /** + * @param Schema|array $schema + * @return array + */ + private static function describe(Schema|array $schema): array + { + if (!$schema instanceof Schema) { + return $schema; + } elseif ( + $schema instanceof Elements\Type + || $schema instanceof Elements\Structure + || $schema instanceof Elements\AnyOf + ) { + return $schema->describe(); + } + throw new Nette\NotSupportedException('Element ' . $schema::class . ' cannot be expressed in JSON Schema.'); + } + + + /** + * @param Schema|array|null $schema + * @return array|\stdClass + */ + private static function buildOrAny(Schema|array|null $schema): array|\stdClass + { + return $schema === null ? new \stdClass : self::build($schema); + } + + + /** + * @param array $item + * @return array + */ + private static function buildString(array $item): array + { + $res = ['type' => 'string'] + self::range($item, 'minLength', 'maxLength'); + if ($item['pattern'] !== null) { + $res['pattern'] = '^(?:' . $item['pattern'] . ')$'; + } + if ($item['format'] !== null) { + $res['format'] = $item['format']; + } + return $res; + } + + + /** + * A PHP array is a JSON array when its keys are integers and an object otherwise. + * @param array $item + * @return array + */ + private static function buildArray(array $item): array + { + $keys = $item['keys'] === null ? null : self::describe($item['keys']); + if ($keys && $keys['kind'] === Kind::Int) { + return ['type' => 'array', 'items' => self::buildOrAny($item['items'])] + + self::range($item, 'minItems', 'maxItems'); + } elseif ($item['items'] === null && $keys === null) { + throw new Nette\NotSupportedException('An array without item type cannot be expressed in JSON Schema; use listOf(), arrayOf() or structure().'); + } + + $res = ['type' => 'object', 'additionalProperties' => self::buildOrAny($item['items'])] + + self::range($item, 'minProperties', 'maxProperties'); + if ($keys && ($keys['pattern'] ?? null) !== null) { + $res['propertyNames'] = ['pattern' => '^(?:' . $keys['pattern'] . ')$']; + } + return $res; + } + + + /** + * @param array $item + * @return array + */ + private static function buildStructure(array $item): array + { + $properties = $required = []; + foreach ($item['shape'] as $key => $property) { + $properties[$key] = self::build($property); + if (self::describe($property)['required']) { + $required[] = (string) $key; + } + } + + return [ + 'type' => 'object', + 'properties' => $properties ?: new \stdClass, + 'required' => $required, + 'additionalProperties' => $item['otherItems'] ? self::build($item['otherItems']) : false, + ] + self::range($item, 'minProperties', 'maxProperties'); + } + + + /** + * The type is stated only when every value shares it. + * @param list $values + * @return array + */ + private static function buildEnum(array $values): array + { + $types = array_unique(array_map(fn($value) => match (true) { + is_string($value) => 'string', + is_int($value) => 'integer', + is_float($value) => 'number', + is_bool($value) => 'boolean', + default => null, + }, $values)); + return (count($types) === 1 && reset($types) !== null ? ['type' => reset($types)] : []) + ['enum' => $values]; + } + + + /** + * Scalar values of an anyOf() come first as one enum, then the schema variants. + * @param array $item + * @return array + */ + private static function buildUnion(array $item): array + { + $variants = array_map(self::build(...), $item['variants']); + if ($item['values'] ?? []) { + array_unshift($variants, self::buildEnum($item['values'])); + } + return ['anyOf' => $variants]; + } + + + /** + * @param array $item + * @return array + */ + private static function range(array $item, string $minKey, string $maxKey): array + { + $number = fn(?float $value) => $value === null ? null : ($value == (int) $value ? (int) $value : $value); + return array_filter([$minKey => $number($item['min']), $maxKey => $number($item['max'])], fn($v) => $v !== null); + } +} diff --git a/src/Schema/Kind.php b/src/Schema/Kind.php new file mode 100644 index 0000000..2d54929 --- /dev/null +++ b/src/Schema/Kind.php @@ -0,0 +1,33 @@ + */ + public static function parse(string $expression): array + { + $variants = []; + $nullable = $dynamic = false; + foreach (explode('|', $expression) as $part) { + if (str_starts_with($part, '?')) { + $nullable = true; + $part = substr($part, 1); + } + + if ($part === 'null') { + $nullable = true; + } elseif ($part === DynamicParameter::class) { + $dynamic = true; + } else { + array_push($variants, ...self::parseAlternative($part)); + } + } + + $item = match (count($variants)) { + 0 => ['kind' => $nullable ? Kind::Null : Kind::Any], + 1 => $variants[0], + default => ['kind' => Kind::Union, 'variants' => $variants], + }; + return $item + ['nullable' => $nullable && count($variants) > 0, 'dynamic' => $dynamic]; + } + + + /** + * One alternative of the expression; 'number' and 'scalar' expand to several. + * @return list> + */ + private static function parseAlternative(string $part): array + { + if (str_ends_with($part, '[]')) { + $items = self::parseAlternative(substr($part, 0, -2)); + return [[ + 'kind' => Kind::Iterable, + 'items' => count($items) === 1 ? $items[0] : ['kind' => Kind::Union, 'variants' => $items], + 'keys' => null, + 'min' => null, + 'max' => null, + ]]; + } + + $parts = explode(':', $part, 2); + $name = $parts[0]; + $arg = $parts[1] ?? null; + [$min, $max] = $arg === null || $name === 'pattern' ? [null, null] : self::parseRange($arg); + $number = fn(Kind $kind) => ['kind' => $kind, 'min' => $min, 'max' => $max]; + $string = fn(?string $format = null, ?string $pattern = null) => ['kind' => Kind::String, 'min' => $min, 'max' => $max, 'pattern' => $pattern, 'format' => $format]; + $collection = fn(Kind $kind) => ['kind' => $kind, 'items' => null, 'keys' => null, 'min' => $min, 'max' => $max]; + + return match ($name) { + 'mixed' => [['kind' => Kind::Any]], + 'bool', 'boolean' => [['kind' => Kind::Bool]], + 'object' => [['kind' => Kind::Object]], + 'callable' => [['kind' => Kind::Callable]], + 'int', 'integer' => [$number(Kind::Int)], + 'float' => [$number(Kind::Float)], + 'number' => [$number(Kind::Int), $number(Kind::Float)], + 'string', 'unicode' => [$string()], + 'email' => [$string(format: 'email')], + 'pattern' => [$string(pattern: $arg)], + 'scalar' => [['kind' => Kind::Bool], $number(Kind::Int), $number(Kind::Float), $string()], + 'array' => [$collection(Kind::Array)], + 'list' => [$collection(Kind::List)], + 'iterable' => [$collection(Kind::Iterable)], + default => [in_array($name, self::Legacy, strict: true) + ? ['kind' => Kind::Other, 'type' => $part] + : ['kind' => Kind::Instance, 'type' => $name]], + }; + } + + + /** + * Bounds written as 'min..max', '..max', 'min..' or an exact 'value'. + * @return array{?float, ?float} + */ + private static function parseRange(string $range): array + { + $bounds = explode('..', $range) + [1 => $range]; + return [ + $bounds[0] === '' ? null : (float) $bounds[0], + $bounds[1] === '' ? null : (float) $bounds[1], + ]; + } +} diff --git a/tests/Schema/Expect.describe.phpt b/tests/Schema/Expect.describe.phpt new file mode 100644 index 0000000..6772044 --- /dev/null +++ b/tests/Schema/Expect.describe.phpt @@ -0,0 +1,153 @@ + Kind::String, 'min' => null, 'max' => null, 'pattern' => null, 'format' => null, 'nullable' => false, 'dynamic' => false, 'required' => false, 'description' => null], + Expect::string()->describe(), + ); + Assert::same(Kind::Int, Expect::int()->describe()['kind']); + Assert::same(Kind::Float, Expect::float()->describe()['kind']); + Assert::same(Kind::Bool, Expect::bool()->describe()['kind']); + Assert::same(Kind::Null, Expect::null()->describe()['kind']); + Assert::same(Kind::Any, Expect::mixed()->describe()['kind']); +}); + + +test('common metadata', function () { + $d = Expect::int()->required()->description('Count')->describe(); + Assert::true($d['required']); + Assert::same('Count', $d['description']); +}); + + +test('nullable and dynamic are flags, not variants', function () { + $d = Expect::int()->nullable()->describe(); + Assert::same(Kind::Int, $d['kind']); + Assert::true($d['nullable']); + + $d = Expect::type('null|int|string')->describe(); + Assert::same(Kind::Union, $d['kind']); + Assert::true($d['nullable']); + Assert::same([Kind::Int, Kind::String], array_map(fn($v) => $v['kind'], $d['variants'])); + + Assert::true(Expect::int()->dynamic()->describe()['dynamic']); +}); + + +test('min(), max() and pattern() narrow the type, a range in the expression too', function () { + $d = Expect::int()->min(1)->max(5)->describe(); + Assert::same(1.0, $d['min']); + Assert::same(5.0, $d['max']); + + $d = Expect::type('int:1..10')->min(3)->max(20)->describe(); + Assert::same(3.0, $d['min']); + Assert::same(10.0, $d['max']); + + $d = Expect::type('int|string')->min(3)->pattern('\d+')->describe(); + Assert::same(3.0, $d['variants'][0]['min']); + Assert::same(3.0, $d['variants'][1]['min']); + Assert::null($d['variants'][0]['pattern'] ?? null); + Assert::same('\d+', $d['variants'][1]['pattern']); + + Assert::same('x', Expect::type('pattern:y')->pattern('x')->describe()['pattern']); + Assert::same('email', Expect::email()->describe()['format']); +}); + + +test('collections report their children as schemas', function () { + $d = Expect::array()->describe(); + Assert::same(Kind::Array, $d['kind']); + Assert::null($d['items']); + + $d = Expect::arrayOf('string', 'int')->describe(); + Assert::type(Elements\Type::class, $d['items']); + Assert::same(Kind::String, $d['items']->describe()['kind']); + Assert::same(Kind::Int, $d['keys']->describe()['kind']); + + $d = Expect::listOf(Expect::int()->min(1))->max(3)->describe(); + Assert::same(Kind::List, $d['kind']); + Assert::same(1.0, $d['items']->describe()['min']); + Assert::same(3.0, $d['max']); + + $d = Expect::type('int[]')->max(3)->describe(); + Assert::same(Kind::Iterable, $d['kind']); + Assert::same(Kind::Int, $d['items']['kind']); // from the expression, not a schema + Assert::same(3.0, $d['max']); + + $d = Expect::type('array|string')->items('int')->describe(); + Assert::type(Elements\Type::class, $d['variants'][0]['items']); + Assert::false(isset($d['variants'][1]['items'])); +}); + + +test('classes and legacy names', function () { + $d = Expect::type(DateTime::class)->describe(); + Assert::same(Kind::Instance, $d['kind']); + Assert::same(DateTime::class, $d['type']); + + $d = Expect::type('numeric')->describe(); + Assert::same(Kind::Other, $d['kind']); + Assert::same('numeric', $d['type']); +}); + + +test('structure', function () { + $schema = Expect::structure([ + 'name' => Expect::string()->required(), + 'age' => Expect::int(), + ])->otherItems('bool')->min(1); + $d = $schema->describe(); + + Assert::same(Kind::Structure, $d['kind']); + Assert::true($d['required']); + Assert::same($schema->getShape(), $d['shape']); + Assert::true($d['shape']['name']->describe()['required']); + Assert::same(Kind::Bool, $d['otherItems']->describe()['kind']); + Assert::same(1, $d['min']); + + Assert::false(Expect::structure([])->required(false)->describe()['required']); +}); + + +test('Expect::from()', function () { + $d = Expect::from(new class { + public string $name; + public ?int $age = null; + })->describe(); + + Assert::same(Kind::Structure, $d['kind']); + Assert::true($d['shape']['name']->describe()['required']); + Assert::same(Kind::Int, $d['shape']['age']->describe()['kind']); + Assert::true($d['shape']['age']->describe()['nullable']); +}); + + +test('anyOf', function () { + $d = Expect::anyOf('a', 'b', 1)->describe(); + Assert::same(Kind::Enum, $d['kind']); + Assert::same(['a', 'b', 1], $d['values']); + Assert::same([], $d['variants']); + Assert::false($d['nullable']); + + $d = Expect::anyOf('a', null)->describe(); + Assert::same(['a'], $d['values']); + Assert::true($d['nullable']); + + $d = Expect::anyOf(Expect::string(), 'a', 'b')->nullable()->dynamic()->describe(); + Assert::same(Kind::Union, $d['kind']); + Assert::true($d['nullable']); + Assert::same(['a', 'b'], $d['values']); + Assert::count(1, $d['variants']); // the variant dynamic() adds is not reported + Assert::type(Elements\Type::class, $d['variants'][0]); + + Assert::same(Kind::Null, Expect::anyOf(null)->describe()['kind']); +}); diff --git a/tests/Schema/JsonSchema.phpt b/tests/Schema/JsonSchema.phpt new file mode 100644 index 0000000..089f35c --- /dev/null +++ b/tests/Schema/JsonSchema.phpt @@ -0,0 +1,211 @@ + 'string'], JsonSchema::export(Expect::string())); + Assert::same(['type' => 'integer'], JsonSchema::export(Expect::int())); + Assert::same(['type' => 'number'], JsonSchema::export(Expect::float())); + Assert::same(['type' => 'boolean'], JsonSchema::export(Expect::bool())); + Assert::same(['type' => 'null'], JsonSchema::export(Expect::null())); + Assert::equal(new stdClass, JsonSchema::export(Expect::mixed())); +}); + + +test('nullable', function () { + Assert::same(['type' => ['string', 'null']], JsonSchema::export(Expect::string()->nullable())); + Assert::same( + ['anyOf' => [['type' => 'integer'], ['type' => 'string'], ['type' => 'null']]], + JsonSchema::export(Expect::type('int|string|null')), + ); + Assert::same( + ['type' => ['string', 'null'], 'enum' => ['a', 'b', null]], + JsonSchema::export(Expect::anyOf('a', 'b')->nullable()), + ); + Assert::equal(new stdClass, JsonSchema::export(Expect::mixed()->nullable())); +}); + + +test('description is exported, default and deprecated are not', function () { + Assert::same( + ['type' => 'integer', 'description' => 'Count'], + JsonSchema::export(Expect::int(5)->description('Count')->deprecated()), + ); +}); + + +test('ranges', function () { + Assert::same(['type' => 'integer', 'minimum' => 1, 'maximum' => 5], JsonSchema::export(Expect::int()->min(1)->max(5))); + Assert::same(['type' => 'number', 'minimum' => 0.5], JsonSchema::export(Expect::float()->min(0.5))); + Assert::same(['type' => 'string', 'minLength' => 1, 'maxLength' => 5], JsonSchema::export(Expect::string()->min(1)->max(5))); + Assert::same(['type' => 'array', 'items' => ['type' => 'string'], 'maxItems' => 3], JsonSchema::export(Expect::listOf('string')->max(3))); + Assert::same( + ['anyOf' => [['type' => 'integer', 'minimum' => 3], ['type' => 'string', 'minLength' => 3]]], + JsonSchema::export(Expect::type('int|string')->min(3)), + ); +}); + + +test('pattern is anchored, format is passed on', function () { + Assert::same(['type' => 'string', 'pattern' => '^(?:\d+)$'], JsonSchema::export(Expect::string()->pattern('\d+'))); + Assert::same(['type' => 'string', 'format' => 'email'], JsonSchema::export(Expect::email())); +}); + + +test('lists and arrays', function () { + Assert::same( + ['type' => 'array', 'items' => ['type' => 'integer']], + JsonSchema::export(Expect::listOf('int')), + ); + Assert::same( + ['type' => 'array', 'items' => ['type' => 'integer']], + JsonSchema::export(Expect::type('int[]')), + ); + Assert::same( + ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], + JsonSchema::export(Expect::arrayOf('int')), + ); + Assert::same( + ['type' => 'array', 'items' => ['type' => 'string']], + JsonSchema::export(Expect::arrayOf('string', 'int')), + ); + Assert::same( + ['type' => 'object', 'additionalProperties' => ['type' => 'string'], 'propertyNames' => ['pattern' => '^(?:[a-z]+)$']], + JsonSchema::export(Expect::arrayOf('string', Expect::string()->pattern('[a-z]+'))), + ); + Assert::equal( + ['type' => 'object', 'additionalProperties' => new stdClass], + JsonSchema::export(Expect::arrayOf('mixed', 'string')), + ); + + Assert::exception( + fn() => JsonSchema::export(Expect::array()), + Nette\NotSupportedException::class, + 'An array without item type cannot be expressed in JSON Schema; use listOf(), arrayOf() or structure().', + ); +}); + + +test('structure', function () { + Assert::same( + [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'Full name'], + 'age' => ['type' => ['integer', 'null']], + 'tags' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['name', 'tags'], + 'additionalProperties' => false, + ], + JsonSchema::export(Expect::structure([ + 'name' => Expect::string()->required()->description('Full name'), + 'age' => Expect::int()->nullable(), + 'tags' => Expect::listOf('string')->required(), + ])), + ); + + Assert::same( + ['type' => 'object', 'properties' => ['a' => ['type' => 'integer']], 'required' => [], 'additionalProperties' => ['type' => 'boolean'], 'minProperties' => 1], + JsonSchema::export(Expect::structure(['a' => Expect::int()])->otherItems('bool')->min(1)), + ); + + Assert::equal( + ['type' => 'object', 'properties' => new stdClass, 'required' => [], 'additionalProperties' => false], + JsonSchema::export(Expect::structure([])), + ); +}); + + +test('nested structure and Expect::from()', function () { + $schema = Expect::from(new class { + public string $name; + public ?int $age = null; + }); + + Assert::same( + [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'age' => ['type' => ['integer', 'null']], + ], + 'required' => ['name'], + 'additionalProperties' => false, + ], + JsonSchema::export($schema), + ); +}); + + +test('enums and unions', function () { + Assert::same(['type' => 'string', 'enum' => ['a', 'b']], JsonSchema::export(Expect::anyOf('a', 'b'))); + Assert::same(['type' => 'integer', 'enum' => [1, 2]], JsonSchema::export(Expect::anyOf(1, 2))); + Assert::same(['enum' => ['a', 1, true]], JsonSchema::export(Expect::anyOf('a', 1, true))); + Assert::same( + ['anyOf' => [['type' => 'string', 'enum' => ['a']], ['type' => 'integer', 'minimum' => 1]]], + JsonSchema::export(Expect::anyOf('a', Expect::int()->min(1))), + ); + Assert::same( + ['anyOf' => [['type' => 'integer'], ['type' => 'number']]], + JsonSchema::export(Expect::type('number')), + ); +}); + + +test('PHP-only types are refused', function () { + Assert::exception( + fn() => JsonSchema::export(Expect::type(DateTime::class)), + Nette\NotSupportedException::class, + "Type 'DateTime' cannot be expressed in JSON Schema.", + ); + Assert::exception( + fn() => JsonSchema::export(Expect::structure(['x' => Expect::type('string|DateTime')])), + Nette\NotSupportedException::class, + "Type 'DateTime' cannot be expressed in JSON Schema.", + ); + Assert::exception( + fn() => JsonSchema::export(Expect::type('numeric')), + Nette\NotSupportedException::class, + "Type 'numeric' cannot be expressed in JSON Schema.", + ); + Assert::exception( + fn() => JsonSchema::export(Expect::type('callable')), + Nette\NotSupportedException::class, + "Type 'callable' cannot be expressed in JSON Schema.", + ); + Assert::exception( + fn() => JsonSchema::export(Expect::type('object')), + Nette\NotSupportedException::class, + "Type 'object' cannot be expressed in JSON Schema.", + ); +}); + + +test('dynamic and casts do not affect the output', function () { + Assert::same(['type' => 'integer'], JsonSchema::export(Expect::int()->dynamic())); + Assert::same(['type' => 'string'], JsonSchema::export(Expect::string()->castTo(DateTime::class)->assert('is_string'))); +}); + + +test('the output is valid JSON', function () { + $json = json_encode(JsonSchema::export(Expect::structure([ + 'any' => Expect::mixed(), + 'empty' => Expect::structure([]), + ]))); + Assert::same( + '{"type":"object","properties":{"any":{},"empty":{"type":"object","properties":{},"required":[],"additionalProperties":false}},"required":["empty"],"additionalProperties":false}', + $json, + ); +}); + + +test('enum of non-scalar values has no type', function () { + Assert::same(['enum' => [[1, 2], 'x']], JsonSchema::export(Expect::anyOf([1, 2], 'x'))); +}); diff --git a/tests/Schema/TypeExpression.phpt b/tests/Schema/TypeExpression.phpt new file mode 100644 index 0000000..a397e5e --- /dev/null +++ b/tests/Schema/TypeExpression.phpt @@ -0,0 +1,145 @@ + $v['kind'], $item['variants']); +} + + +test('PHP types', function () { + Assert::same(Kind::String, TypeExpression::parse('string')['kind']); + Assert::same(Kind::Int, TypeExpression::parse('int')['kind']); + Assert::same(Kind::Int, TypeExpression::parse('integer')['kind']); + Assert::same(Kind::Float, TypeExpression::parse('float')['kind']); + Assert::same(Kind::Bool, TypeExpression::parse('bool')['kind']); + Assert::same(Kind::Bool, TypeExpression::parse('boolean')['kind']); + Assert::same(Kind::Null, TypeExpression::parse('null')['kind']); + Assert::same(Kind::Any, TypeExpression::parse('mixed')['kind']); + Assert::same(Kind::Array, TypeExpression::parse('array')['kind']); + Assert::same(Kind::List, TypeExpression::parse('list')['kind']); + Assert::same(Kind::Iterable, TypeExpression::parse('iterable')['kind']); + Assert::same(Kind::Object, TypeExpression::parse('object')['kind']); + Assert::same(Kind::Callable, TypeExpression::parse('callable')['kind']); + + Assert::same(['kind' => Kind::Bool, 'nullable' => false, 'dynamic' => false], TypeExpression::parse('bool')); + Assert::same( + ['kind' => Kind::Int, 'min' => null, 'max' => null, 'nullable' => false, 'dynamic' => false], + TypeExpression::parse('int'), + ); +}); + + +test('nullable and dynamic are flags', function () { + $d = TypeExpression::parse('?int'); + Assert::same(Kind::Int, $d['kind']); + Assert::true($d['nullable']); + + $d = TypeExpression::parse('null|int'); + Assert::same(Kind::Int, $d['kind']); + Assert::true($d['nullable']); + + $d = TypeExpression::parse('int|string|null'); + Assert::same(Kind::Union, $d['kind']); + Assert::true($d['nullable']); + Assert::same([Kind::Int, Kind::String], kinds($d)); + + $d = TypeExpression::parse(DynamicParameter::class . '|int'); + Assert::same(Kind::Int, $d['kind']); + Assert::true($d['dynamic']); + + $d = TypeExpression::parse(DynamicParameter::class); + Assert::same(Kind::Any, $d['kind']); + Assert::true($d['dynamic']); + + Assert::false(TypeExpression::parse('null')['nullable']); +}); + + +test('unions and composite names', function () { + Assert::same([Kind::Int, Kind::String], kinds(TypeExpression::parse('int|string'))); + Assert::same([Kind::Int, Kind::Float], kinds(TypeExpression::parse('number'))); + Assert::same([Kind::Bool, Kind::Int, Kind::Float, Kind::String], kinds(TypeExpression::parse('scalar'))); + Assert::same([Kind::Int, Kind::Float, Kind::String], kinds(TypeExpression::parse('number|string'))); +}); + + +test('ranges from the expression', function () { + $d = TypeExpression::parse('int:1..5'); + Assert::same(1.0, $d['min']); + Assert::same(5.0, $d['max']); + + $d = TypeExpression::parse('string:..5'); + Assert::null($d['min']); + Assert::same(5.0, $d['max']); + + $d = TypeExpression::parse('array:1..'); + Assert::same(1.0, $d['min']); + Assert::null($d['max']); + + $d = TypeExpression::parse('array:1'); + Assert::same(1.0, $d['min']); + Assert::same(1.0, $d['max']); + + $d = TypeExpression::parse('int:1..5|string:3'); + Assert::same(1.0, $d['variants'][0]['min']); + Assert::same(3.0, $d['variants'][1]['min']); +}); + + +test('string flavors', function () { + $d = TypeExpression::parse('unicode:2..4'); + Assert::same(Kind::String, $d['kind']); + Assert::same(2.0, $d['min']); + + $d = TypeExpression::parse('email'); + Assert::same(Kind::String, $d['kind']); + Assert::same('email', $d['format']); + + $d = TypeExpression::parse('pattern:[a-z]+'); + Assert::same(Kind::String, $d['kind']); + Assert::same('[a-z]+', $d['pattern']); + Assert::null($d['min']); + + Assert::same('\d{1..3}', TypeExpression::parse('pattern:\d{1..3}')['pattern']); +}); + + +test('T[] is any iterable of T, as Validators::is() sees it', function () { + $d = TypeExpression::parse('int[]'); + Assert::same(Kind::Iterable, $d['kind']); + Assert::same(Kind::Int, $d['items']['kind']); + + $d = TypeExpression::parse('int[][]'); + Assert::same(Kind::Iterable, $d['items']['kind']); + Assert::same(Kind::Int, $d['items']['items']['kind']); + + Assert::same(Kind::Union, TypeExpression::parse('number[]')['items']['kind']); + Assert::same([Kind::Iterable, Kind::String], kinds(TypeExpression::parse('int[]|string'))); +}); + + +test('an unknown name is a class name, a legacy validator is Other', function () { + $d = TypeExpression::parse(DateTime::class); + Assert::same(Kind::Instance, $d['kind']); + Assert::same(DateTime::class, $d['type']); + + Assert::same(Kind::Instance, TypeExpression::parse('foo')['kind']); + Assert::same([Kind::Instance, Kind::Instance], kinds(TypeExpression::parse('DateTime|DateTimeInterface'))); + + foreach (['numeric', 'numericint', 'file', 'directory', 'url', 'uri', 'identifier', 'alnum', 'alpha', 'digit', 'lower', 'upper', 'space', 'xdigit', 'class', 'interface', 'resource', 'none'] as $name) { + $d = TypeExpression::parse($name); + Assert::same(Kind::Other, $d['kind'], $name); + Assert::same($name, $d['type']); + } + + Assert::same('numeric:1..3', TypeExpression::parse('numeric:1..3')['type']); +}); From 511ba0f14dfafbedb01ec30cd0df4d559f31014a Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Dec 2020 22:49:42 +0100 Subject: [PATCH 05/21] opened 2.0-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6535ac9..c08148e 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.4-dev" + "dev-master": "2.0-dev" } }, "config": { From 5e682334f9f7b81293dc025c52283015517203b9 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 9 Mar 2026 02:37:12 +0100 Subject: [PATCH 06/21] Schema: added return type hints (BC break) --- src/Schema/Schema.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index 619ebcf..b91c7e7 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -15,25 +15,21 @@ interface Schema { /** * Applies pre-processing transformations to the raw input value (e.g., via before() hooks). - * @return mixed */ - function normalize(mixed $value, Context $context); + function normalize(mixed $value, Context $context): mixed; /** * Merges two normalized values, with $value taking priority over $base. - * @return mixed */ - function merge(mixed $value, mixed $base); + function merge(mixed $value, mixed $base): mixed; /** * Validates the value and applies defaults, transforms, and assertions. - * @return mixed */ - function complete(mixed $value, Context $context); + function complete(mixed $value, Context $context): mixed; /** * Returns the default value, or adds a missing-item error if the field is required. - * @return mixed */ - function completeDefault(Context $context); + function completeDefault(Context $context): mixed; } From 22b8d15ed5802ae3ed7cd5a05cdbbdb3499416df Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 17 Aug 2026 20:13:27 +0200 Subject: [PATCH 07/21] Expect::from() removed support for phpDoc annotations (BC break) --- readme.md | 22 +----- src/Schema/Expect.php | 5 +- src/Schema/Helpers.php | 41 +---------- tests/Schema/Expect.from.php80.phpt | 55 --------------- tests/Schema/Expect.from.phpt | 76 ++++++++++----------- tests/Schema/Helpers.getPropertyType.phpt | 35 ---------- tests/Schema/Helpers.parseAnnotation().phpt | 58 ---------------- 7 files changed, 40 insertions(+), 252 deletions(-) delete mode 100644 tests/Schema/Expect.from.php80.phpt delete mode 100644 tests/Schema/Helpers.getPropertyType.phpt delete mode 100644 tests/Schema/Helpers.parseAnnotation().phpt diff --git a/readme.md b/readme.md index 7e52878..001726c 100644 --- a/readme.md +++ b/readme.md @@ -485,12 +485,9 @@ You can generate structure schema from the class. Example: ```php class Config { - /** @var string */ - public $name; - /** @var string|null */ - public $password; - /** @var bool */ - public $admin = false; + public string $name; + public ?string $password; + public bool $admin = false; } $schema = Expect::from(new Config); @@ -504,19 +501,6 @@ $normalized = $processor->process($schema, $data); // $normalized = {'name' => 'jeff', 'password' => null, 'admin' => false} ``` -If you are using PHP 7.4 or higher, you can use native types: - -```php -class Config -{ - public string $name; - public ?string $password; - public bool $admin = false; -} - -$schema = Expect::from(new Config); -``` - Anonymous classes are also supported: ```php diff --git a/src/Schema/Expect.php b/src/Schema/Expect.php index 44cf25b..d1dad7d 100644 --- a/src/Schema/Expect.php +++ b/src/Schema/Expect.php @@ -84,14 +84,11 @@ public static function from(object $object, array $items = []): Structure foreach ($props as $prop) { $name = $prop->getName(); if (!isset($items[$name])) { - $type = Helpers::getPropertyType($prop) ?? 'mixed'; - $item = new Type($type); + $item = new Type((string) (Nette\Utils\Type::fromReflection($prop) ?? 'mixed')); if ($prop instanceof \ReflectionProperty ? $prop->isInitialized($object) : $prop->isOptional()) { $def = ($prop instanceof \ReflectionProperty ? $prop->getValue($object) : $prop->getDefaultValue()); if (is_object($def)) { $item = static::from($def); - } elseif ($def === null && !Nette\Utils\Validators::is(null, $type)) { - $item->required(); } else { $item->default($def); } diff --git a/src/Schema/Helpers.php b/src/Schema/Helpers.php index e15bf50..aa7e7b3 100644 --- a/src/Schema/Helpers.php +++ b/src/Schema/Helpers.php @@ -8,8 +8,7 @@ namespace Nette\Schema; use Nette; -use Nette\Utils\Reflection; -use function array_map, count, explode, get_debug_type, implode, in_array, is_array, is_float, is_int, is_object, is_scalar, is_string, is_subclass_of, method_exists, preg_match, preg_quote, preg_replace, preg_replace_callback, settype, str_replace, strlen, trim, var_export; +use function array_map, count, explode, get_debug_type, implode, in_array, is_array, is_float, is_int, is_object, is_scalar, is_string, is_subclass_of, method_exists, preg_match, settype, str_replace, strlen, var_export; /** @@ -54,44 +53,6 @@ public static function merge(mixed $value, mixed $base): mixed } - /** - * Returns the type of a property or parameter as a string, or null if not determinable. - */ - public static function getPropertyType(\ReflectionProperty|\ReflectionParameter $prop): ?string - { - if ($type = Nette\Utils\Type::fromReflection($prop)) { - return (string) $type; - } elseif ( - ($prop instanceof \ReflectionProperty) - && ($type = preg_replace('#\s.*#', '', (string) self::parseAnnotation($prop, 'var'))) - ) { - $class = Reflection::getPropertyDeclaringClass($prop); - return preg_replace_callback('#[\w\\\]+#', fn($m) => Reflection::expandClassName($m[0], $class), $type); - } - - return null; - } - - - /** - * Returns an annotation value. - * @param \ReflectionClass|\ReflectionProperty $ref - */ - public static function parseAnnotation(\ReflectionClass|\ReflectionProperty $ref, string $name): ?string - { - if (!Reflection::areCommentsAvailable()) { - throw new Nette\InvalidStateException('You have to enable phpDoc comments in opcode cache.'); - } - - $re = '#[\s*]@' . preg_quote($name, '#') . '(?=\s|$)(?:[ \t]+([^@\s]\S*))?#'; - if ($ref->getDocComment() && preg_match($re, trim($ref->getDocComment(), '/*'), $m)) { - return $m[1] ?? ''; - } - - return null; - } - - /** * Formats a value for use in error messages (e.g., 'hello', true, object stdClass). */ diff --git a/tests/Schema/Expect.from.php80.phpt b/tests/Schema/Expect.from.php80.phpt deleted file mode 100644 index da59b0a..0000000 --- a/tests/Schema/Expect.from.php80.phpt +++ /dev/null @@ -1,55 +0,0 @@ - Expect::string('mysql'), - 'user' => Expect::type('?string')->required(), - 'password' => Expect::type('?string'), - 'options' => Expect::type('array|int')->default([]), - 'debugger' => Expect::bool(true), - 'mixed' => Expect::mixed()->required(), - 'arr' => Expect::type('array')->default([1]), - ], $schema->items); - Assert::type($obj, (new Processor)->process($schema, ['user' => '', 'mixed' => ''])); -}); - - -Assert::with(Structure::class, function () { // constructor injection - $schema = Expect::from($obj = new class ('') { - public function __construct( - public ?string $user, - public ?string $password = null, - ) { - } - }); - - Assert::type(Structure::class, $schema); - Assert::equal([ - 'user' => Expect::type('?string')->required(), - 'password' => Expect::type('?string'), - ], $schema->items); - Assert::equal( - new $obj('foo', 'bar'), - (new Processor)->process($schema, ['user' => 'foo', 'password' => 'bar']), - ); -}); diff --git a/tests/Schema/Expect.from.phpt b/tests/Schema/Expect.from.phpt index 6ca6f2e..2baca48 100644 --- a/tests/Schema/Expect.from.phpt +++ b/tests/Schema/Expect.from.phpt @@ -20,76 +20,70 @@ Assert::with(Structure::class, function () { Assert::with(Structure::class, function () { $schema = Expect::from($obj = new class { - /** @var string */ - public $dsn = 'mysql'; - - /** @var string|null */ - public $user; - - /** @var ?string */ - public $password; - - /** @var string[] */ - public $options = [1]; - - /** @var bool */ - public $debugger = true; - public $mixed; - - /** @var array|null */ - public $arr; - - /** @var string */ - public $required; + public string $dsn = 'mysql'; + public ?string $user; + public ?string $password = null; + public array|int $options = []; + public bool $debugger = true; + public mixed $mixed; + public array $arr = [1]; }); Assert::type(Structure::class, $schema); Assert::equal([ 'dsn' => Expect::string('mysql'), - 'user' => Expect::type('string|null'), + 'user' => Expect::type('?string')->required(), 'password' => Expect::type('?string'), - 'options' => Expect::type('string[]')->default([1]), + 'options' => Expect::type('array|int')->default([]), 'debugger' => Expect::bool(true), - 'mixed' => Expect::mixed(), - 'arr' => Expect::type('array|null')->default(null), - 'required' => Expect::type('string')->required(), + 'mixed' => Expect::mixed()->required(), + 'arr' => Expect::type('array')->default([1]), ], $schema->items); - Assert::type($obj, (new Processor)->process($schema, ['required' => ''])); + Assert::type($obj, (new Processor)->process($schema, ['user' => '', 'mixed' => ''])); }); -Assert::exception(function () { - Expect::from(new class { - /** @var Unknown */ - public $unknown; +Assert::with(Structure::class, function () { // constructor injection + $schema = Expect::from($obj = new class ('') { + public function __construct( + public ?string $user, + public ?string $password = null, + ) { + } }); -}, Nette\NotImplementedException::class, 'Anonymous classes are not supported.'); + + Assert::type(Structure::class, $schema); + Assert::equal([ + 'user' => Expect::type('?string')->required(), + 'password' => Expect::type('?string'), + ], $schema->items); + Assert::equal( + new $obj('foo', 'bar'), + (new Processor)->process($schema, ['user' => 'foo', 'password' => 'bar']), + ); +}); Assert::with(Structure::class, function () { // overwritten item $schema = Expect::from(new class { - /** @var string */ - public $dsn = 'mysql'; + public string $dsn = 'mysql'; - /** @var string|null */ - public $user; + public ?string $user; }, ['dsn' => Expect::int(123)]); Assert::equal([ 'dsn' => Expect::int(123), - 'user' => Expect::type('string|null'), + 'user' => Expect::type('?string')->required(), ], $schema->items); }); Assert::with(Structure::class, function () { // nested object $obj = new class { - /** @var object */ - public $inner; + public object $inner; }; $obj->inner = new class { - /** @var string */ - public $name; + public string $name; }; $schema = Expect::from($obj); diff --git a/tests/Schema/Helpers.getPropertyType.phpt b/tests/Schema/Helpers.getPropertyType.phpt deleted file mode 100644 index c12e95c..0000000 --- a/tests/Schema/Helpers.getPropertyType.phpt +++ /dev/null @@ -1,35 +0,0 @@ - Date: Sun, 28 Apr 2024 23:11:29 +0200 Subject: [PATCH 08/21] Expect::from() works with class names --- src/Schema/Expect.php | 47 +++++--- ...ect.from.phpt => Expect.from.dynamic.phpt} | 0 tests/Schema/Expect.from.static.phpt | 107 ++++++++++++++++++ 3 files changed, 139 insertions(+), 15 deletions(-) rename tests/Schema/{Expect.from.phpt => Expect.from.dynamic.phpt} (100%) create mode 100644 tests/Schema/Expect.from.static.phpt diff --git a/src/Schema/Expect.php b/src/Schema/Expect.php index d1dad7d..a5cb9ba 100644 --- a/src/Schema/Expect.php +++ b/src/Schema/Expect.php @@ -71,32 +71,49 @@ public static function structure(array $shape): Structure /** - * Generates a structure schema from a class instance by reflecting its properties or constructor parameters. - * @param array $items Optional overrides for specific properties. + * Generates a structure schema from a class by reflecting its properties or constructor parameters. + * @param class-string|object $object + * @param array $items */ - public static function from(object $object, array $items = []): Structure + public static function from(object|string $object, array $items = []): Structure { - $ro = new \ReflectionObject($object); + $ro = new \ReflectionClass($object); $props = $ro->hasMethod('__construct') ? $ro->getMethod('__construct')->getParameters() : $ro->getProperties(); foreach ($props as $prop) { $name = $prop->getName(); - if (!isset($items[$name])) { - $item = new Type((string) (Nette\Utils\Type::fromReflection($prop) ?? 'mixed')); - if ($prop instanceof \ReflectionProperty ? $prop->isInitialized($object) : $prop->isOptional()) { - $def = ($prop instanceof \ReflectionProperty ? $prop->getValue($object) : $prop->getDefaultValue()); - if (is_object($def)) { - $item = static::from($def); - } else { - $item->default($def); - } + if (isset($items[$name])) { + continue; + } + + $item = new Type($propType = (string) (Nette\Utils\Type::fromReflection($prop) ?? 'mixed')); + if (class_exists($propType)) { + $item = static::from($propType); + } + + $hasDefault = match (true) { + $prop instanceof \ReflectionParameter => $prop->isOptional(), + is_object($object) => $prop->isInitialized($object), + default => $prop->hasDefaultValue(), + }; + if ($hasDefault) { + $default = match (true) { + $prop instanceof \ReflectionParameter => $prop->getDefaultValue(), + is_object($object) => $prop->getValue($object), + default => $prop->getDefaultValue(), + }; + if (is_object($default)) { + $item = static::from($default); } else { - $item->required(); + $item->default($default); } - $items[$name] = $item; + } else { + $item->required(); } + + $items[$name] = $item; } return (new Structure($items))->castTo($ro->getName()); diff --git a/tests/Schema/Expect.from.phpt b/tests/Schema/Expect.from.dynamic.phpt similarity index 100% rename from tests/Schema/Expect.from.phpt rename to tests/Schema/Expect.from.dynamic.phpt diff --git a/tests/Schema/Expect.from.static.phpt b/tests/Schema/Expect.from.static.phpt new file mode 100644 index 0000000..0bf488e --- /dev/null +++ b/tests/Schema/Expect.from.static.phpt @@ -0,0 +1,107 @@ +items); + Assert::type(stdClass::class, (new Processor)->process($schema, [])); +}); + + +Assert::with(Structure::class, function () { + class Data1 + { + public string $dsn = 'mysql'; + public ?string $user; + public ?string $password = null; + public array|int $options = []; + public bool $debugger = true; + public mixed $mixed; + public array $arr = [1]; + } + + $schema = Expect::from(Data1::class); + + Assert::type(Structure::class, $schema); + Assert::equal([ + 'dsn' => Expect::string('mysql'), + 'user' => Expect::type('?string')->required(), + 'password' => Expect::type('?string'), + 'options' => Expect::type('array|int')->default([]), + 'debugger' => Expect::bool(true), + 'mixed' => Expect::mixed()->required(), + 'arr' => Expect::type('array')->default([1]), + ], $schema->items); + Assert::type(Data1::class, (new Processor)->process($schema, ['user' => '', 'mixed' => ''])); +}); + + +Assert::with(Structure::class, function () { // constructor injection + class Data2 + { + public function __construct( + public ?string $user, + public ?string $password = null, + ) { + } + } + + $schema = Expect::from(Data2::class); + + Assert::type(Structure::class, $schema); + Assert::equal([ + 'user' => Expect::type('?string')->required(), + 'password' => Expect::type('?string'), + ], $schema->items); + Assert::equal( + new Data2('foo', 'bar'), + (new Processor)->process($schema, ['user' => 'foo', 'password' => 'bar']), + ); +}); + + +Assert::with(Structure::class, function () { // overwritten item + class Data3 + { + public string $dsn = 'mysql'; + public ?string $user; + } + + $schema = Expect::from(Data3::class, ['dsn' => Expect::int(123)]); + + Assert::equal([ + 'dsn' => Expect::int(123), + 'user' => Expect::type('?string')->required(), + ], $schema->items); +}); + + +Assert::with(Structure::class, function () { // nested object + class Data4 + { + public Data5 $inner; + } + + class Data5 + { + public string $name; + } + + $schema = Expect::from(Data4::class); + + Assert::equal([ + 'inner' => Expect::structure([ + 'name' => Expect::string()->required(), + ])->castTo(Data5::class), + ], $schema->items); +}); From 003c17d3185925841fb6c0a0601178a426014b11 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 23 Nov 2024 17:53:33 +0100 Subject: [PATCH 09/21] used #Deprecated --- src/Schema/Elements/Base.php | 10 ++++------ src/Schema/Message.php | 16 ++++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index 918ca98..30a8076 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -174,7 +174,7 @@ private function doTransform(mixed $value, Context $context): mixed } - /** @deprecated use Nette\Schema\Helpers::validateType() */ + #[\Deprecated('use Nette\Schema\Helpers::validateType()')] private function doValidate(mixed $value, string $expected, Context $context): bool { $isOk = $context->createChecker(); @@ -183,10 +183,8 @@ private function doValidate(mixed $value, string $expected, Context $context): b } - /** - * @deprecated use Nette\Schema\Helpers::validateRange() - * @param array{?float, ?float} $range - */ + /** @param array{?float, ?float} $range */ + #[\Deprecated('use Nette\Schema\Helpers::validateRange()')] private static function doValidateRange(mixed $value, array $range, Context $context, string $types = ''): bool { $isOk = $context->createChecker(); @@ -195,7 +193,7 @@ private static function doValidateRange(mixed $value, array $range, Context $con } - /** @deprecated use doTransform() */ + #[\Deprecated('use doTransform()')] private function doFinalize(mixed $value, Context $context): mixed { return $this->doTransform($value, $context); diff --git a/src/Schema/Message.php b/src/Schema/Message.php index f06f172..9c537fd 100644 --- a/src/Schema/Message.php +++ b/src/Schema/Message.php @@ -39,28 +39,28 @@ final class Message /** no variables */ public const Deprecated = 'schema.deprecated'; - /** @deprecated use Message::TypeMismatch */ + #[\Deprecated('use Message::TypeMismatch')] public const TYPE_MISMATCH = self::TypeMismatch; - /** @deprecated use Message::ValueOutOfRange */ + #[\Deprecated('use Message::ValueOutOfRange')] public const VALUE_OUT_OF_RANGE = self::ValueOutOfRange; - /** @deprecated use Message::LengthOutOfRange */ + #[\Deprecated('use Message::LengthOutOfRange')] public const LENGTH_OUT_OF_RANGE = self::LengthOutOfRange; - /** @deprecated use Message::PatternMismatch */ + #[\Deprecated('use Message::PatternMismatch')] public const PATTERN_MISMATCH = self::PatternMismatch; - /** @deprecated use Message::FailedAssertion */ + #[\Deprecated('use Message::FailedAssertion')] public const FAILED_ASSERTION = self::FailedAssertion; - /** @deprecated use Message::MissingItem */ + #[\Deprecated('use Message::MissingItem')] public const MISSING_ITEM = self::MissingItem; - /** @deprecated use Message::UnexpectedItem */ + #[\Deprecated('use Message::UnexpectedItem')] public const UNEXPECTED_ITEM = self::UnexpectedItem; - /** @deprecated use Message::Deprecated */ + #[\Deprecated('use Message::Deprecated')] public const DEPRECATED = self::Deprecated; From dd16cb6efff2a1e5d73badcfcfbcbb885145c9a0 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:15:29 +0200 Subject: [PATCH 10/21] Schema::merge() receives Context and reports merge errors through it (BC break) --- src/Schema/Elements/AnyOf.php | 2 +- src/Schema/Elements/Structure.php | 10 ++++++---- src/Schema/Elements/Type.php | 10 ++++++---- src/Schema/Processor.php | 3 ++- src/Schema/Schema.php | 4 ++-- tests/Schema/Expect.array.phpt | 5 +++-- tests/Schema/heterogenous.phpt | 2 +- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Schema/Elements/AnyOf.php b/src/Schema/Elements/AnyOf.php index 8fe5406..409c646 100644 --- a/src/Schema/Elements/AnyOf.php +++ b/src/Schema/Elements/AnyOf.php @@ -110,7 +110,7 @@ public function normalize(mixed $value, Context $context): mixed } - public function merge(mixed $value, mixed $base): mixed + public function merge(mixed $value, mixed $base, Context $context): mixed { if (is_array($value) && isset($value[Helpers::PreventMerging])) { unset($value[Helpers::PreventMerging]); diff --git a/src/Schema/Elements/Structure.php b/src/Schema/Elements/Structure.php index 838f9c9..9c29611 100644 --- a/src/Schema/Elements/Structure.php +++ b/src/Schema/Elements/Structure.php @@ -150,7 +150,7 @@ public function normalize(mixed $value, Context $context): mixed } - public function merge(mixed $value, mixed $base): mixed + public function merge(mixed $value, mixed $base, Context $context): mixed { if (is_array($value) && isset($value[Helpers::PreventMerging])) { unset($value[Helpers::PreventMerging]); @@ -163,10 +163,12 @@ public function merge(mixed $value, mixed $base): mixed if ($key === $index) { $base[] = $val; $index++; + } elseif (array_key_exists($key, $base) && ($itemSchema = $this->items[$key] ?? $this->otherItems)) { + $context->path[] = $key; + $base[$key] = $itemSchema->merge($val, $base[$key], $context); + array_pop($context->path); } else { - $base[$key] = array_key_exists($key, $base) && ($itemSchema = $this->items[$key] ?? $this->otherItems) - ? $itemSchema->merge($val, $base[$key]) - : $val; + $base[$key] = $val; } } diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index 8959188..a41dd78 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -177,7 +177,7 @@ public function normalize(mixed $value, Context $context): mixed } - public function merge(mixed $value, mixed $base): mixed + public function merge(mixed $value, mixed $base, Context $context): mixed { if (is_array($value) && isset($value[Helpers::PreventMerging])) { unset($value[Helpers::PreventMerging]); @@ -190,10 +190,12 @@ public function merge(mixed $value, mixed $base): mixed if ($key === $index) { $base[] = $val; $index++; + } elseif (array_key_exists($key, $base)) { + $context->path[] = $key; + $base[$key] = $this->itemsValue->merge($val, $base[$key], $context); + array_pop($context->path); } else { - $base[$key] = array_key_exists($key, $base) - ? $this->itemsValue->merge($val, $base[$key]) - : $val; + $base[$key] = $val; } } diff --git a/src/Schema/Processor.php b/src/Schema/Processor.php index 3ce576a..9b0d8f3 100644 --- a/src/Schema/Processor.php +++ b/src/Schema/Processor.php @@ -58,7 +58,8 @@ public function processMultiple(Schema $schema, array $dataset): mixed foreach ($dataset as $data) { $data = $schema->normalize($data, $this->context); $this->throwErrors(); - $flatten = $first ? $data : $schema->merge($data, $flatten); + $flatten = $first ? $data : $schema->merge($data, $flatten, $this->context); + $this->throwErrors(); $first = false; } diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index b91c7e7..1047ff0 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -19,9 +19,9 @@ interface Schema function normalize(mixed $value, Context $context): mixed; /** - * Merges two normalized values, with $value taking priority over $base. + * Merges two normalized values, with $value taking priority over $base. Merge errors are reported via Context. */ - function merge(mixed $value, mixed $base): mixed; + function merge(mixed $value, mixed $base, Context $context): mixed; /** * Validates the value and applies defaults, transforms, and assertions. diff --git a/tests/Schema/Expect.array.phpt b/tests/Schema/Expect.array.phpt index 46369fd..b88f8ee 100644 --- a/tests/Schema/Expect.array.phpt +++ b/tests/Schema/Expect.array.phpt @@ -184,6 +184,7 @@ test('merging & other items validation', function () { test('merging & other items validation', function () { $schema = Expect::array()->items('string'); + $context = new Nette\Schema\Context; Assert::same([ 'key1' => 'val1', @@ -193,7 +194,7 @@ test('merging & other items validation', function () { 'key1' => 'val1', 'key2' => 'val2', 'val3', - ], null)); + ], null, $context)); Assert::same( [ @@ -211,7 +212,7 @@ test('merging & other items validation', function () { 'key1' => 'val1', 'key2' => 'val2', 'val3', - ]), + ], $context), ); }); diff --git a/tests/Schema/heterogenous.phpt b/tests/Schema/heterogenous.phpt index 7550d0d..ddd6ff4 100644 --- a/tests/Schema/heterogenous.phpt +++ b/tests/Schema/heterogenous.phpt @@ -18,7 +18,7 @@ class MySchema implements Schema } - public function merge(mixed $value, mixed $base): mixed + public function merge(mixed $value, mixed $base, Context $context): mixed { return $base . $value; } From d6b4657e5f3edeedd3b4e1b1d63182ba08679a75 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:15:56 +0200 Subject: [PATCH 11/21] Type: mergeDefaults() are disabled by default (BC break) [Closes #28, Closes #31] --- readme.md | 2 +- src/Schema/Elements/Type.php | 9 +++++---- tests/Schema/Expect.array.phpt | 34 +++++++++++++++------------------- tests/Schema/Expect.list.phpt | 12 ++++++------ 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/readme.md b/readme.md index 001726c..356a327 100644 --- a/readme.md +++ b/readme.md @@ -176,7 +176,7 @@ The parameter can also be a schema, so we can write: Expect::arrayOf(Expect::bool()) ``` -The default value is an empty array. If you specify a default value, it will be merged with the passed data. This can be disabled using `mergeDefaults(false)`. +The default value is an empty array. If you specify a default value and call `mergeDefaults()`, it will be merged with the passed data. Enumeration: anyOf() diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index a41dd78..6718e0c 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -28,7 +28,7 @@ final class Type implements Schema /** @var array{?float, ?float} */ private array $range = [null, null]; private ?string $pattern = null; - private bool $merge = true; + private bool $merge = false; public function __construct(string $type) @@ -49,11 +49,12 @@ public function nullable(): self } - /** - * Controls whether the default value is merged with the input array (enabled by default). - */ + #[\Deprecated('mergeDefaults is disabled by default')] public function mergeDefaults(bool $state = true): self { + if ($state === true) { + trigger_error(__METHOD__ . '() is deprecated and will be removed in the next major version.', E_USER_DEPRECATED); + } $this->merge = $state; return $this; } diff --git a/tests/Schema/Expect.array.phpt b/tests/Schema/Expect.array.phpt index b88f8ee..897555f 100644 --- a/tests/Schema/Expect.array.phpt +++ b/tests/Schema/Expect.array.phpt @@ -49,13 +49,13 @@ test('nullable array accepts null', function () { }); -test('not merging', function () { +test('not merging default value', function () { $schema = Expect::array([ 'key1' => 'val1', 'key2' => 'val2', 'val3', 'arr' => ['item'], - ])->mergeDefaults(false); + ]); Assert::same([], (new Processor)->process($schema, [])); @@ -66,13 +66,13 @@ test('not merging', function () { }); -test('merging', function () { - $schema = Expect::array([ +test('merging default value', function () { + $schema = @Expect::array([ // mergeDefaults() is deprecated 'key1' => 'val1', 'key2' => 'val2', 'val3', 'arr' => ['item'], - ]); + ])->mergeDefaults(true); Assert::same([ 'key1' => 'val1', @@ -144,12 +144,12 @@ test('merging', function () { }); -test('merging & other items validation', function () { - $schema = Expect::array([ +test('merging default value & other items validation', function () { + $schema = @Expect::array([ // mergeDefaults() is deprecated 'key1' => 'val1', 'key2' => 'val2', 'val3', - ])->items('string'); + ])->mergeDefaults(true)->items('string'); Assert::same([ 'key1' => 'val1', @@ -182,7 +182,7 @@ test('merging & other items validation', function () { }); -test('merging & other items validation', function () { +test('merging layers & other items validation', function () { $schema = Expect::array()->items('string'); $context = new Nette\Schema\Context; @@ -218,11 +218,9 @@ test('merging & other items validation', function () { test('items() & scalar', function () { - $schema = Expect::array([ - 'a' => 'defval', - ])->items('string'); + $schema = Expect::array()->items('string'); - Assert::same(['a' => 'defval'], (new Processor)->process($schema, [])); + Assert::same([], (new Processor)->process($schema, [])); checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, [1, 2, 3]); @@ -246,16 +244,14 @@ test('items() & scalar', function () { (new Processor)->process($schema, ['b' => null]); }, ["The item 'b' expects to be string, null given."]); - Assert::same(['a' => 'defval', 'b' => 'val'], (new Processor)->process($schema, ['b' => 'val'])); + Assert::same(['b' => 'val'], (new Processor)->process($schema, ['b' => 'val'])); }); test('items() & structure', function () { - $schema = Expect::array([ - 'a' => 'defval', - ])->items(Expect::structure(['k' => Expect::string()])); + $schema = Expect::array([])->items(Expect::structure(['k' => Expect::string()])); - Assert::same(['a' => 'defval'], (new Processor)->process($schema, [])); + Assert::same([], (new Processor)->process($schema, [])); checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, ['a' => 'val']); @@ -278,7 +274,7 @@ test('items() & structure', function () { }, ["Unexpected item 'b\u{a0}›\u{a0}a', did you mean 'k'?"]); Assert::equal( - ['a' => 'defval', 'b' => (object) ['k' => 'val']], + ['b' => (object) ['k' => 'val']], (new Processor)->process($schema, ['b' => ['k' => 'val']]), ); }); diff --git a/tests/Schema/Expect.list.phpt b/tests/Schema/Expect.list.phpt index 33603d8..afaa284 100644 --- a/tests/Schema/Expect.list.phpt +++ b/tests/Schema/Expect.list.phpt @@ -35,8 +35,8 @@ test('without default value', function () { }); -test('not merging', function () { - $schema = Expect::list([1, 2, 3])->mergeDefaults(false); +test('not merging default value', function () { + $schema = Expect::list([1, 2, 3]); Assert::same([], (new Processor)->process($schema, [])); @@ -46,8 +46,8 @@ test('not merging', function () { }); -test('merging', function () { - $schema = Expect::list([1, 2, 3]); +test('merging default value', function () { + $schema = @Expect::list([1, 2, 3])->mergeDefaults(true); // mergeDefaults() is deprecated Assert::same([1, 2, 3], (new Processor)->process($schema, [])); @@ -57,8 +57,8 @@ test('merging', function () { }); -test('merging & other items validation', function () { - $schema = Expect::list([1, 2, 3])->items('string'); +test('merging default value & other items validation', function () { + $schema = @Expect::list([1, 2, 3])->mergeDefaults(true)->items('string'); // mergeDefaults() is deprecated Assert::same([1, 2, 3], (new Processor)->process($schema, [])); From e09605f802b38749b6dfc8b66689d4713cfe59fe Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:18:21 +0200 Subject: [PATCH 12/21] added MergeMode enum, mergeMode() and mergeWith(); Type::merge() merges arrays only according to the schema (BC break) --- src/Schema/Elements/Base.php | 27 ++++++ src/Schema/Elements/Type.php | 27 +++++- src/Schema/MergeMode.php | 24 +++++ src/Schema/Message.php | 3 + tests/Schema/Expect.merging.phpt | 162 +++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/Schema/MergeMode.php create mode 100644 tests/Schema/Expect.merging.phpt diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index 30a8076..d70906e 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -10,6 +10,7 @@ use Nette; use Nette\Schema\Context; use Nette\Schema\Helpers; +use Nette\Schema\MergeMode; use function count, is_string; @@ -28,6 +29,10 @@ trait Base private array $transforms = []; private ?string $deprecated = null; private ?string $description = null; + private ?MergeMode $mergeMode = null; + + /** @var ?\Closure(mixed, mixed): mixed */ + private ?\Closure $mergeWith = null; public function default(mixed $value): self @@ -55,6 +60,28 @@ public function before(callable $handler): self } + /** + * Sets how array values are combined when merging multiple configuration layers. + */ + public function mergeMode(MergeMode $mode): self + { + $this->mergeMode = $mode; + return $this; + } + + + /** + * Sets a custom strategy combining two layers. Must be a pure combiner; canonicalize layer shape in before() instead. + * Either side may be null, a layer can legally be null. + * @param callable(mixed, mixed): mixed $fn + */ + public function mergeWith(callable $fn): self + { + $this->mergeWith = $fn(...); + return $this; + } + + /** * Casts the validated value to a built-in type or instantiates the given class. */ diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index 6718e0c..aec084c 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -11,6 +11,8 @@ use Nette\Schema\DynamicParameter; use Nette\Schema\Helpers; use Nette\Schema\Kind; +use Nette\Schema\MergeMode; +use Nette\Schema\Message; use Nette\Schema\Schema; use Nette\Schema\TypeExpression; use Nette\Utils\Validators; @@ -185,17 +187,34 @@ public function merge(mixed $value, mixed $base, Context $context): mixed return $value; } - if (is_array($value) && is_array($base) && $this->itemsValue) { - $index = 0; + if ($this->mergeWith) { + return ($this->mergeWith)($value, $base); + } + + $mode = $this->mergeMode ?? MergeMode::AppendKeys; + if ($mode === MergeMode::Replace) { + return $value; + } + + if (is_array($value) && is_array($base)) { + $index = $mode === MergeMode::OverwriteKeys ? null : 0; foreach ($value as $key => $val) { if ($key === $index) { $base[] = $val; $index++; - } elseif (array_key_exists($key, $base)) { + } elseif (!array_key_exists($key, $base)) { + $base[$key] = $val; + } elseif ($this->itemsValue) { $context->path[] = $key; $base[$key] = $this->itemsValue->merge($val, $base[$key], $context); array_pop($context->path); } else { + if (is_array($val) && is_array($base[$key]) && $this->mergeMode === null) { + $context->addError( + 'Cannot merge %path%: the schema does not describe array items, use arrayOf() or mergeMode().', + Message::CannotMerge, + )->path[] = $key; + } $base[$key] = $val; } } @@ -203,7 +222,7 @@ public function merge(mixed $value, mixed $base, Context $context): mixed return $base; } - return Helpers::merge($value, $base); + return $value === null && is_array($base) ? $base : $value; } diff --git a/src/Schema/MergeMode.php b/src/Schema/MergeMode.php new file mode 100644 index 0000000..0d3e945 --- /dev/null +++ b/src/Schema/MergeMode.php @@ -0,0 +1,24 @@ + Expect::array()->mergeMode(MergeMode::Replace), + 'foo2' => Expect::array()->mergeMode(MergeMode::OverwriteKeys), + 'foo3' => Expect::array()->mergeMode(MergeMode::AppendKeys), + ]); + + Assert::equal( + (object) [ + 'foo1' => ['key' => 'new'], + 'foo2' => ['new', 'key' => 'new'], + 'foo3' => ['old', 'new', 'key' => 'new'], + ], + (new Processor)->processMultiple($schema, [ + [ + 'foo1' => ['old', 'key' => '1'], + 'foo2' => ['old', 'key' => '1'], + 'foo3' => ['old', 'key' => '1'], + ], + [ + 'foo1' => ['key' => 'new'], + 'foo2' => ['new', 'key' => 'new'], + 'foo3' => ['new', 'key' => 'new'], + ], + ]), + ); +}); + + +test('Replace mode replaces the whole value', function () { + $schema = Expect::array()->mergeMode(MergeMode::Replace); + + Assert::same( + [3], + (new Processor)->processMultiple($schema, [[1, 2], [3]]), + ); +}); + + +test('ambiguous merge of untyped arrays reports error', function () { + $schema = Expect::structure([ + 'a' => Expect::array(), + ]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->processMultiple($schema, [ + ['a' => ['x' => ['deep' => 1], 'y' => 2]], + ['a' => ['x' => ['deep2' => 3]]], + ]); + }, ["Cannot merge 'a\u{a0}›\u{a0}x': the schema does not describe array items, use arrayOf() or mergeMode()."]); +}); + + +test('explicit mergeMode() opts out of the ambiguity error', function () { + $schema = Expect::structure([ + 'a' => Expect::array()->mergeMode(MergeMode::AppendKeys), + ]); + + Assert::equal( + (object) ['a' => ['x' => ['deep2' => 3], 'y' => 2]], + (new Processor)->processMultiple($schema, [ + ['a' => ['x' => ['deep' => 1], 'y' => 2]], + ['a' => ['x' => ['deep2' => 3]]], + ]), + ); +}); + + +test('items schema drives deep merging', function () { + $schema = Expect::arrayOf(Expect::array()); + + Assert::equal( + ['x' => ['deep' => 1, 'deep2' => 3], 'y' => [2]], + (new Processor)->processMultiple($schema, [ + ['x' => ['deep' => 1], 'y' => [2]], + ['x' => ['deep2' => 3]], + ]), + ); +}); + + +test('scalar collisions in untyped arrays overwrite silently', function () { + $schema = Expect::structure([ + 'a' => Expect::array(), + ]); + + Assert::equal( + (object) ['a' => ['k' => 9, 'j' => 2]], + (new Processor)->processMultiple($schema, [ + ['a' => ['k' => 1, 'j' => 2]], + ['a' => ['k' => 9]], + ]), + ); +}); + + +test('mergeWith() combines scalars', function () { + $schema = Expect::structure([ + 'debug' => Expect::bool()->mergeWith(fn($value, $base) => $value || $base), + 'level' => Expect::int()->mergeWith(fn($value, $base) => max($value, $base)), + ]); + + Assert::equal( + (object) ['debug' => true, 'level' => 7], + (new Processor)->processMultiple($schema, [ + ['debug' => true, 'level' => 7], + ['debug' => false, 'level' => 3], + ]), + ); +}); + + +test('mergeWith() as deep-merge escape hatch', function () { + $deep = function ($value, $base) use (&$deep) { + if (is_array($value) && is_array($base)) { + foreach ($value as $k => $v) { + $base[$k] = array_key_exists($k, $base) ? $deep($v, $base[$k]) : $v; + } + return $base; + } + return $value; + }; + + $schema = Expect::structure([ + 'a' => Expect::array()->mergeWith($deep), + ]); + + Assert::equal( + (object) ['a' => ['x' => ['deep' => 1, 'deep2' => 3], 'y' => 2]], + (new Processor)->processMultiple($schema, [ + ['a' => ['x' => ['deep' => 1], 'y' => 2]], + ['a' => ['x' => ['deep2' => 3]]], + ]), + ); +}); + + +test('null layer does not overwrite an array but overwrites a scalar', function () { + $schema = Expect::structure([ + 'arr' => Expect::array(), + 'scalar' => Expect::mixed(), + ]); + + Assert::equal( + (object) ['arr' => [1], 'scalar' => null], + (new Processor)->processMultiple($schema, [ + ['arr' => [1], 'scalar' => 'a'], + ['arr' => null, 'scalar' => null], + ]), + ); +}); From 177524a3046be69d2afb6f2d21e56ef5ee7d114f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:19:16 +0200 Subject: [PATCH 13/21] Structure::merge() driven by MergeMode; numeric-key appending decoupled from otherItems (BC break) --- src/Schema/Elements/Structure.php | 24 +++++++++-- tests/Schema/Expect.merging.phpt | 68 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/Schema/Elements/Structure.php b/src/Schema/Elements/Structure.php index 9c29611..c268356 100644 --- a/src/Schema/Elements/Structure.php +++ b/src/Schema/Elements/Structure.php @@ -11,6 +11,7 @@ use Nette\Schema\Context; use Nette\Schema\Helpers; use Nette\Schema\Kind; +use Nette\Schema\MergeMode; use Nette\Schema\Schema; use function array_diff_key, array_fill_keys, array_key_exists, array_keys, array_map, array_merge, array_pop, array_values, is_array, is_object, strval; @@ -157,17 +158,34 @@ public function merge(mixed $value, mixed $base, Context $context): mixed $base = null; } + if ($this->mergeWith) { + return ($this->mergeWith)($value, $base); + } + + $mode = $this->mergeMode ?? ($this->otherItems === null ? MergeMode::OverwriteKeys : MergeMode::AppendKeys); + if ($mode === MergeMode::Replace) { + return $value; + } + if (is_array($value) && is_array($base)) { - $index = $this->otherItems === null ? null : 0; + $index = $mode === MergeMode::OverwriteKeys ? null : 0; foreach ($value as $key => $val) { if ($key === $index) { $base[] = $val; $index++; - } elseif (array_key_exists($key, $base) && ($itemSchema = $this->items[$key] ?? $this->otherItems)) { + } elseif (!array_key_exists($key, $base)) { + $base[$key] = $val; + } elseif ($itemSchema = $this->items[$key] ?? $this->otherItems) { $context->path[] = $key; $base[$key] = $itemSchema->merge($val, $base[$key], $context); array_pop($context->path); } else { + if (is_array($val) && is_array($base[$key]) && $this->mergeMode === null) { + $context->addError( + 'Cannot merge %path%: the schema does not describe the item, use otherItems() or mergeMode().', + Nette\Schema\Message::CannotMerge, + )->path[] = $key; + } $base[$key] = $val; } } @@ -175,7 +193,7 @@ public function merge(mixed $value, mixed $base, Context $context): mixed return $base; } - return $value ?? $base; + return $value === null && is_array($base) ? $base : $value; } diff --git a/tests/Schema/Expect.merging.phpt b/tests/Schema/Expect.merging.phpt index 68fe4a7..3bb0a39 100644 --- a/tests/Schema/Expect.merging.phpt +++ b/tests/Schema/Expect.merging.phpt @@ -146,6 +146,74 @@ test('mergeWith() as deep-merge escape hatch', function () { }); +test('merge modes on structures', function () { + $schema = Expect::structure([ + 'foo1' => Expect::structure([ + 'key' => Expect::string(), + 0 => Expect::string(), + ])->mergeMode(MergeMode::Replace), + 'foo2' => Expect::structure([ + 'key' => Expect::string(), + 0 => Expect::string(), + ])->mergeMode(MergeMode::OverwriteKeys), + 'foo3' => Expect::structure([ + 'key' => Expect::string(), + 0 => Expect::string(), + ])->mergeMode(MergeMode::AppendKeys)->otherItems('string'), + ]); + + Assert::equal( + (object) [ + 'foo1' => (object) [null, 'key' => 'new'], + 'foo2' => (object) ['new', 'key' => 'new'], + 'foo3' => (object) ['old', 'new', 'key' => 'new'], + ], + (new Processor)->processMultiple($schema, [ + [ + 'foo1' => ['old', 'key' => '1'], + 'foo2' => ['old', 'key' => '1'], + 'foo3' => ['old', 'key' => '1'], + ], + [ + 'foo1' => ['key' => 'new'], + 'foo2' => ['new', 'key' => 'new'], + 'foo3' => ['new', 'key' => 'new'], + ], + ]), + ); +}); + + +test('structure appends numeric keys only with otherItems (derived mode)', function () { + $schema = Expect::structure([ + 'key' => Expect::string(), + 0 => Expect::string(), + ])->otherItems('string'); + + Assert::equal( + (object) ['old', 'key' => 'new', 'new'], + (new Processor)->processMultiple($schema, [ + ['old', 'key' => '1'], + ['new', 'key' => 'new'], + ]), + ); +}); + + +test('unknown structure item colliding as arrays reports error', function () { + $schema = Expect::structure([ + 'known' => Expect::string(), + ]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->processMultiple($schema, [ + ['extra' => ['a' => 1]], + ['extra' => ['b' => 2]], + ]); + }, ["Cannot merge 'extra': the schema does not describe the item, use otherItems() or mergeMode()."]); +}); + + test('null layer does not overwrite an array but overwrites a scalar', function () { $schema = Expect::structure([ 'arr' => Expect::array(), From 23527d0fb308c516bcb0320854779a347051b740 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:21:08 +0200 Subject: [PATCH 14/21] AnyOf::merge() merges layers according to the matched alternative instead of blindly (BC break) --- src/Schema/Context.php | 3 ++ src/Schema/Elements/AnyOf.php | 52 +++++++++++++++++++++- src/Schema/Elements/Base.php | 8 +++- tests/Schema/Expect.merging.phpt | 76 ++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/Schema/Context.php b/src/Schema/Context.php index b42b1ed..2e7d4ad 100644 --- a/src/Schema/Context.php +++ b/src/Schema/Context.php @@ -17,6 +17,9 @@ final class Context { public bool $skipDefaults = false; + /** @internal probe mode: validation-only completion, tolerant to missing required items, transforms are skipped */ + public bool $isPartial = false; + /** @var list */ public array $path = []; diff --git a/src/Schema/Elements/AnyOf.php b/src/Schema/Elements/AnyOf.php index 409c646..a1a85d9 100644 --- a/src/Schema/Elements/AnyOf.php +++ b/src/Schema/Elements/AnyOf.php @@ -11,6 +11,7 @@ use Nette\Schema\Context; use Nette\Schema\Helpers; use Nette\Schema\Kind; +use Nette\Schema\MergeMode; use Nette\Schema\Schema; use function array_merge, array_unique, implode, is_array; @@ -117,7 +118,54 @@ public function merge(mixed $value, mixed $base, Context $context): mixed return $value; } - return Helpers::merge($value, $base); + if ($this->mergeWith) { + return ($this->mergeWith)($value, $base); + } + + if ($this->mergeMode === MergeMode::Replace + || $value instanceof Nette\Schema\DynamicParameter + || $base instanceof Nette\Schema\DynamicParameter + || $base === null + ) { + return $value; + } + + if ($value === null) { + return is_array($base) ? $base : $value; + } + + foreach ($this->set as $item) { + if ($item instanceof Schema + ? $this->matches($value, $item, $context) && $this->matches($base, $item, $context) + : $item === $value && $item === $base + ) { + return $item instanceof Schema + ? $item->merge($value, $base, $context) + : $value; + } + } + + if (is_array($value) && is_array($base)) { + $context->addError( + 'Cannot merge %path%: layers do not match the same alternative.', + Nette\Schema\Message::CannotMerge, + ); + } + + return $value; + } + + + /** + * Checks whether the (possibly partial) layer would validate against the given alternative. + */ + private function matches(mixed $value, Schema $schema, Context $context): bool + { + $dolly = new Context; + $dolly->path = $context->path; + $dolly->isPartial = true; + $schema->complete($schema->normalize($value, $dolly), $dolly); + return !$dolly->errors; } @@ -181,7 +229,7 @@ private function findAlternative(mixed $value, Context $context): mixed public function completeDefault(Context $context): mixed { - if ($this->required) { + if ($this->required && !$context->isPartial) { $context->addError( 'The mandatory item %path% is missing.', Nette\Schema\Message::MissingItem, diff --git a/src/Schema/Elements/Base.php b/src/Schema/Elements/Base.php index d70906e..269f7ca 100644 --- a/src/Schema/Elements/Base.php +++ b/src/Schema/Elements/Base.php @@ -155,7 +155,7 @@ protected function describeBase(): array public function completeDefault(Context $context): mixed { - if ($this->required) { + if ($this->required && !$context->isPartial) { $context->addError( 'The mandatory item %path% is missing.', Nette\Schema\Message::MissingItem, @@ -179,7 +179,7 @@ public function doNormalize(mixed $value, Context $context): mixed private function doDeprecation(Context $context): void { - if ($this->deprecated !== null) { + if ($this->deprecated !== null && !$context->isPartial) { $context->addWarning( $this->deprecated, Nette\Schema\Message::Deprecated, @@ -190,6 +190,10 @@ private function doDeprecation(Context $context): void private function doTransform(mixed $value, Context $context): mixed { + if ($context->isPartial) { + return $value; + } + $isOk = $context->createChecker(); foreach ($this->transforms as $handler) { $value = $handler($value, $context); diff --git a/tests/Schema/Expect.merging.phpt b/tests/Schema/Expect.merging.phpt index 3bb0a39..83b379f 100644 --- a/tests/Schema/Expect.merging.phpt +++ b/tests/Schema/Expect.merging.phpt @@ -228,3 +228,79 @@ test('null layer does not overwrite an array but overwrites a scalar', function ]), ); }); + + +test('layers matching the same anyOf alternative merge by it (nette/database#223)', function () { + $connection = Expect::structure([ + 'dsn' => Expect::string()->required(), + 'user' => Expect::string(), + 'options' => Expect::array(), + ]); + $schema = Expect::anyOf($connection, Expect::arrayOf($connection)); + + Assert::equal( + (object) ['options' => ['lazy' => true], 'dsn' => 'sqlite:', 'user' => 'x'], + (new Processor)->processMultiple($schema, [ + ['options' => ['lazy' => true]], + ['dsn' => 'sqlite:', 'user' => 'x'], + ]), + ); +}); + + +test('anyOf layers matching different alternatives cannot merge arrays', function () { + $schema = Expect::anyOf( + Expect::structure(['a' => Expect::string()]), + Expect::listOf('string'), + ); + + checkValidationErrors(function () use ($schema) { + (new Processor)->processMultiple($schema, [ + ['a' => 'x'], + ['s1', 's2'], + ]); + }, ['Cannot merge: layers do not match the same alternative.']); +}); + + +test('scalar anyOf layer replaces array and vice versa (proxy case)', function () { + $schema = Expect::anyOf(Expect::string(), Expect::arrayOf('string')); + + Assert::same(['5.6.7.8'], (new Processor)->processMultiple($schema, ['1.2.3.4', ['5.6.7.8']])); + Assert::same('1.2.3.4', (new Processor)->processMultiple($schema, [['5.6.7.8'], '1.2.3.4'])); + Assert::same(['a', 'b'], (new Processor)->processMultiple($schema, [['a'], ['b']])); +}); + + +test('explicit Replace and mergeWith() on anyOf', function () { + $schema = Expect::anyOf(Expect::arrayOf('string'), Expect::bool()) + ->mergeMode(MergeMode::Replace); + + Assert::same(['c'], (new Processor)->processMultiple($schema, [['a', 'b'], ['c']])); + + $schema = Expect::anyOf(Expect::int(), Expect::bool()) + ->mergeWith(fn($value, $base) => $value + $base); + + Assert::same(5, (new Processor)->processMultiple($schema, [2, 3])); +}); + + +test('null layer in anyOf follows the null rule', function () { + $schema = Expect::anyOf(Expect::arrayOf('string'), Expect::string())->nullable(); + + Assert::same(['a'], (new Processor)->processMultiple($schema, [['a'], null])); + + // no array variant, otherwise findAlternative would coerce the merged null to [] + $schema = Expect::anyOf(Expect::string(), Expect::bool())->nullable(); + + Assert::same(null, (new Processor)->processMultiple($schema, ['s', null])); +}); + + +test('variant reshaping in before() merges as replace (documented limitation)', function () { + $variant = Expect::arrayOf('string') + ->before(fn($v) => is_string($v) ? explode(',', $v) : $v); + $schema = Expect::anyOf($variant, Expect::bool()); + + Assert::same(['c', 'd'], (new Processor)->processMultiple($schema, ['a,b', 'c,d'])); +}); From 9fab8ead774e8964813cc9e9733d6c3abb97ba52 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:23:22 +0200 Subject: [PATCH 15/21] removed support for key PreventMerging, removed Helpers::merge() (BC break) --- src/Schema/Elements/AnyOf.php | 6 ---- src/Schema/Elements/Structure.php | 13 -------- src/Schema/Elements/Type.php | 44 ++++++++++++++------------- src/Schema/Helpers.php | 35 ---------------------- src/Schema/Processor.php | 28 +++++++++++++++++ tests/Schema/Expect.array.phpt | 33 -------------------- tests/Schema/Expect.merging.phpt | 22 ++++++++++++++ tests/Schema/Helpers.merge.phpt | 50 ------------------------------- 8 files changed, 74 insertions(+), 157 deletions(-) delete mode 100644 tests/Schema/Helpers.merge.phpt diff --git a/src/Schema/Elements/AnyOf.php b/src/Schema/Elements/AnyOf.php index a1a85d9..1ad5fd8 100644 --- a/src/Schema/Elements/AnyOf.php +++ b/src/Schema/Elements/AnyOf.php @@ -9,7 +9,6 @@ use Nette; use Nette\Schema\Context; -use Nette\Schema\Helpers; use Nette\Schema\Kind; use Nette\Schema\MergeMode; use Nette\Schema\Schema; @@ -113,11 +112,6 @@ public function normalize(mixed $value, Context $context): mixed public function merge(mixed $value, mixed $base, Context $context): mixed { - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); - return $value; - } - if ($this->mergeWith) { return ($this->mergeWith)($value, $base); } diff --git a/src/Schema/Elements/Structure.php b/src/Schema/Elements/Structure.php index c268356..adf3b9c 100644 --- a/src/Schema/Elements/Structure.php +++ b/src/Schema/Elements/Structure.php @@ -123,10 +123,6 @@ public function describe(): array public function normalize(mixed $value, Context $context): mixed { - if ($prevent = (is_array($value) && isset($value[Helpers::PreventMerging]))) { - unset($value[Helpers::PreventMerging]); - } - $value = $this->doNormalize($value, $context); if (is_object($value)) { $value = (array) $value; @@ -141,10 +137,6 @@ public function normalize(mixed $value, Context $context): mixed array_pop($context->path); } } - - if ($prevent) { - $value[Helpers::PreventMerging] = true; - } } return $value; @@ -153,11 +145,6 @@ public function normalize(mixed $value, Context $context): mixed public function merge(mixed $value, mixed $base, Context $context): mixed { - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); - $base = null; - } - if ($this->mergeWith) { return ($this->mergeWith)($value, $base); } diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index aec084c..f9704c9 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -151,10 +151,6 @@ public function describe(): array public function normalize(mixed $value, Context $context): mixed { - if ($prevent = (is_array($value) && isset($value[Helpers::PreventMerging]))) { - unset($value[Helpers::PreventMerging]); - } - $value = $this->doNormalize($value, $context); if (is_array($value) && $this->itemsValue) { $res = []; @@ -172,21 +168,12 @@ public function normalize(mixed $value, Context $context): mixed $value = $res; } - if ($prevent && is_array($value)) { - $value[Helpers::PreventMerging] = true; - } - return $value; } public function merge(mixed $value, mixed $base, Context $context): mixed { - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); - return $value; - } - if ($this->mergeWith) { return ($this->mergeWith)($value, $base); } @@ -228,12 +215,6 @@ public function merge(mixed $value, mixed $base, Context $context): mixed public function complete(mixed $value, Context $context): mixed { - $merge = $this->merge; - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); - $merge = false; - } - if ($value === null && is_array($this->default) && !Validators::is(null, $this->type)) { $value = []; // is unable to distinguish null from array in NEON } @@ -245,7 +226,7 @@ public function complete(mixed $value, Context $context): mixed $isOk() && Helpers::validateRange($value, $this->range, $context, $this->type); $isOk() && $value !== null && $this->pattern !== null && Helpers::validatePattern($value, $this->pattern, $context); $isOk() && is_array($value) && $this->validateItems($value, $context); - $isOk() && $merge && $value !== null && $value = Helpers::merge($value, $this->default); + $isOk() && $this->merge && $value !== null && $value = self::deepMerge($value, $this->default); $isOk() && $value = $this->doTransform($value, $context); if (!$isOk()) { return null; @@ -259,6 +240,29 @@ public function complete(mixed $value, Context $context): mixed } + /** + * Blind deep merge used only by deprecated mergeDefaults(). + */ + private static function deepMerge(mixed $value, mixed $base): mixed + { + if (is_array($value) && is_array($base)) { + $index = 0; + foreach ($value as $key => $val) { + if ($key === $index) { + $base[] = $val; + $index++; + } else { + $base[$key] = self::deepMerge($val, $base[$key] ?? null); + } + } + + return $base; + } + + return $value === null && is_array($base) ? $base : $value; + } + + /** @param array $value */ private function validateItems(array &$value, Context $context): void { diff --git a/src/Schema/Helpers.php b/src/Schema/Helpers.php index aa7e7b3..7869660 100644 --- a/src/Schema/Helpers.php +++ b/src/Schema/Helpers.php @@ -18,41 +18,6 @@ final class Helpers { use Nette\StaticClass; - public const PreventMerging = '_prevent_merging'; - - - /** - * Merges dataset. Left has higher priority than right one. - */ - public static function merge(mixed $value, mixed $base): mixed - { - if (is_array($value) && isset($value[self::PreventMerging])) { - unset($value[self::PreventMerging]); - return $value; - } - - if (is_array($value) && is_array($base)) { - $index = 0; - foreach ($value as $key => $val) { - if ($key === $index) { - $base[] = $val; - $index++; - } else { - $base[$key] = static::merge($val, $base[$key] ?? null); - } - } - - return $base; - - } elseif ($value === null && is_array($base)) { - return $base; - - } else { - return $value; - } - } - - /** * Formats a value for use in error messages (e.g., 'hello', true, object stdClass). */ diff --git a/src/Schema/Processor.php b/src/Schema/Processor.php index 9b0d8f3..96d7b33 100644 --- a/src/Schema/Processor.php +++ b/src/Schema/Processor.php @@ -37,6 +37,7 @@ public function skipDefaults(bool $value = true): void public function process(Schema $schema, mixed $data): mixed { $this->createContext(); + $this->rejectPreventMerging($data); $data = $schema->normalize($data, $this->context); $this->throwErrors(); $data = $schema->complete($data, $this->context); @@ -56,6 +57,7 @@ public function processMultiple(Schema $schema, array $dataset): mixed $flatten = null; $first = true; foreach ($dataset as $data) { + $this->rejectPreventMerging($data); $data = $schema->normalize($data, $this->context); $this->throwErrors(); $flatten = $first ? $data : $schema->merge($data, $flatten, $this->context); @@ -86,6 +88,32 @@ public function getWarnings(): array } + /** + * Transitional guard: the magic key was removed in 2.0 and must fail loudly, not flow through as data. + */ + private function rejectPreventMerging(mixed $data): void + { + if ($data instanceof \stdClass) { + $data = (array) $data; + } + + if (is_array($data)) { + if (array_key_exists('_prevent_merging', $data)) { + $this->context->addError( + 'The key %path% is no longer supported, use mergeMode() instead.', + Message::CannotMerge, + )->path[] = '_prevent_merging'; + } + + foreach ($data as $key => $val) { + $this->context->path[] = $key; + $this->rejectPreventMerging($val); + array_pop($this->context->path); + } + } + } + + private function throwErrors(): void { if ($this->context->errors) { diff --git a/tests/Schema/Expect.array.phpt b/tests/Schema/Expect.array.phpt index 897555f..1719223 100644 --- a/tests/Schema/Expect.array.phpt +++ b/tests/Schema/Expect.array.phpt @@ -1,7 +1,6 @@ 'newval', - 'key3' => 'newval', - 'newval3', - 'arr' => ['newitem'], - ], - (new Processor)->process($schema, [ - Helpers::PreventMerging => true, - 'key1' => 'newval', - 'key3' => 'newval', - 'newval3', - 'arr' => ['newitem'], - ]), - ); - - Assert::same( - [ - 'key1' => 'newval', - 'key2' => 'val2', - 'val3', - 'arr' => ['newitem'], - 'key3' => 'newval', - 'newval3', - ], - (new Processor)->process($schema, [ - 'key1' => 'newval', - 'key3' => 'newval', - 'newval3', - 'arr' => [Helpers::PreventMerging => true, 'newitem'], - ]), - ); }); diff --git a/tests/Schema/Expect.merging.phpt b/tests/Schema/Expect.merging.phpt index 83b379f..d9cb97c 100644 --- a/tests/Schema/Expect.merging.phpt +++ b/tests/Schema/Expect.merging.phpt @@ -230,6 +230,28 @@ test('null layer does not overwrite an array but overwrites a scalar', function }); +test('removed _prevent_merging key is rejected loudly', function () { + $schema = Expect::array(); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, ['_prevent_merging' => true, 'a' => 1]); + }, ["The key '_prevent_merging' is no longer supported, use mergeMode() instead."]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->processMultiple($schema, [ + ['a' => ['_prevent_merging' => true, 'b' => 1]], + ]); + }, ["The key 'a\u{a0}›\u{a0}_prevent_merging' is no longer supported, use mergeMode() instead."]); + + checkValidationErrors(function () { + $schema = Expect::structure(['a' => Expect::array()]); + (new Processor)->processMultiple($schema, [ + (object) ['a' => ['_prevent_merging' => true, 'x' => 1]], + ]); + }, ["The key 'a\u{a0}›\u{a0}_prevent_merging' is no longer supported, use mergeMode() instead."]); +}); + + test('layers matching the same anyOf alternative merge by it (nette/database#223)', function () { $connection = Expect::structure([ 'dsn' => Expect::string()->required(), diff --git a/tests/Schema/Helpers.merge.phpt b/tests/Schema/Helpers.merge.phpt deleted file mode 100644 index 39eb2f0..0000000 --- a/tests/Schema/Helpers.merge.phpt +++ /dev/null @@ -1,50 +0,0 @@ - 'b', 'x']; -$arr2 = ['c' => 'd', 'y']; -$arr3 = [Helpers::PreventMerging => true, 'c' => 'd', 'y']; - - -Assert::same(null, Helpers::merge(null, null)); -Assert::same(null, Helpers::merge(null, 231)); -Assert::same(null, Helpers::merge(null, $obj)); -Assert::same([], Helpers::merge(null, [])); -Assert::same($arr1, Helpers::merge(null, $arr1)); -Assert::same(231, Helpers::merge(231, null)); -Assert::same(231, Helpers::merge(231, 231)); -Assert::same(231, Helpers::merge(231, $obj)); -Assert::same(231, Helpers::merge(231, [])); -Assert::same(231, Helpers::merge(231, $arr1)); -Assert::same($obj, Helpers::merge($obj, null)); -Assert::same($obj, Helpers::merge($obj, 231)); -Assert::same($obj, Helpers::merge($obj, $obj)); -Assert::same($obj, Helpers::merge($obj, [])); -Assert::same($obj, Helpers::merge($obj, $arr1)); -Assert::same([], Helpers::merge([], null)); -Assert::same([], Helpers::merge([], 231)); -Assert::same([], Helpers::merge([], $obj)); -Assert::same([], Helpers::merge([], [])); -Assert::same($arr1, Helpers::merge([], $arr1)); -Assert::same($arr2, Helpers::merge($arr2, null)); -Assert::same($arr2, Helpers::merge($arr2, 231)); -Assert::same($arr2, Helpers::merge($arr2, $obj)); -Assert::same($arr2, Helpers::merge($arr2, [])); -Assert::same(['a' => 'b', 'x', 'c' => 'd', 'y'], Helpers::merge($arr2, $arr1)); -Assert::same(['c' => 'd', 'y'], Helpers::merge($arr3, $arr1)); -Assert::same(['inner' => ['c' => 'd', 'y']], Helpers::merge(['inner' => $arr3], ['inner' => $arr1])); -Assert::same([['a' => 'b', 'x'], [Helpers::PreventMerging => true, 'c' => 'd', 'y']], Helpers::merge([$arr3], [$arr1])); -Assert::same([Helpers::PreventMerging => true, 'c' => 'd', 'y', 'a' => 'b', 'x'], Helpers::merge($arr1, $arr3)); -Assert::same([20 => 'b', 10 => 'a'], Helpers::merge([10 => 'a'], [20 => 'b'])); -Assert::same(['b', 'a'], Helpers::merge(['a'], ['b'])); From 55f54d7dbd0a2571ec4ed45d129f1cb51980968e Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 04:24:17 +0200 Subject: [PATCH 16/21] added Expect::tuple() --- src/Schema/Expect.php | 14 +++ tests/Schema/Expect.tuple.phpt | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 tests/Schema/Expect.tuple.phpt diff --git a/src/Schema/Expect.php b/src/Schema/Expect.php index a5cb9ba..fb22af1 100644 --- a/src/Schema/Expect.php +++ b/src/Schema/Expect.php @@ -150,4 +150,18 @@ public static function listOf(string|Schema $type): Type { return (new Type('list'))->items($type); } + + + /** + * Creates a fixed-size array where each position has its own schema; a later layer replaces the tuple wholesale. + * @param Schema[] $shape + */ + public static function tuple(array $shape): Structure + { + if (!array_is_list($shape)) { + throw new Nette\InvalidArgumentException('Tuple shape must be indexed array.'); + } + + return (new Structure($shape))->castTo('array')->mergeMode(MergeMode::Replace); + } } diff --git a/tests/Schema/Expect.tuple.phpt b/tests/Schema/Expect.tuple.phpt new file mode 100644 index 0000000..4418e0a --- /dev/null +++ b/tests/Schema/Expect.tuple.phpt @@ -0,0 +1,153 @@ +process($schema, [])); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, [1, 2, 3]); + }, ["Unexpected item '0'.", "Unexpected item '1'.", "Unexpected item '2'."]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, ['key' => 'val']); + }, ["Unexpected item 'key'."]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, 'one'); + }, ["The item expects to be array, 'one' given."]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, true); + }, ['The item expects to be array, true given.']); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, 123); + }, ['The item expects to be array, 123 given.']); + + Assert::equal([], (new Processor)->process($schema, null)); +}); + + +testException('non-indexed array', function () { + $schema = Expect::tuple(['a' => Expect::string()]); +}, Nette\InvalidArgumentException::class, 'Tuple shape must be indexed array.'); + + +test('accepts object', function () { + $schema = Expect::tuple([Expect::string()]); + + Assert::equal([null], (new Processor)->process($schema, [])); + + Assert::equal(['foo'], (new Processor)->process($schema, ['foo'])); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, [1]); + }, ["The item '0' expects to be string, 1 given."]); + + $schema = Expect::tuple([Expect::string()->before('strrev')]); + + Assert::equal(['oof'], (new Processor)->process($schema, ['foo'])); + + Assert::equal( + ['rab'], + (new Processor)->processMultiple($schema, [['foo'], ['bar']]), + ); +}); + + +test('scalar items', function () { + $schema = Expect::tuple([ + Expect::string(), + Expect::int(), + Expect::bool(), + Expect::scalar(), + Expect::type('string'), + Expect::type('int'), + Expect::string('abc'), + Expect::string(123), + Expect::type('string')->default(123), + Expect::anyOf(1, 2), + ]); + + Assert::equal( + [null, null, null, null, null, null, 'abc', 123, 123, null], + (new Processor)->process($schema, []), + ); +}); + + +testException( + 'default value must be readonly', + fn() => Expect::tuple([])->default([]), + Nette\InvalidStateException::class, +); + + +test('with items', function () { + $schema = Expect::tuple([ + Expect::string(), + Expect::arrayOf('string'), + ]); + + $processor = new Processor; + + Assert::equal( + [null, []], + $processor->process($schema, []), + ); + + Assert::equal( + [null, []], + $processor->processMultiple($schema, []), + ); + + checkValidationErrors(function () use ($processor, $schema) { + $processor->process($schema, [1, 2, 3]); + }, [ + "Unexpected item '2'.", + "The item '0' expects to be string, 1 given.", + "The item '1' expects to be array, 2 given.", + ]); + + Assert::equal( + ['newval3', []], + $processor->process($schema, ['newval3']), + ); + + Assert::equal( + ['newval4', []], + $processor->processMultiple($schema, [['newval2', 'newval3'], ['newval4']]), + ); +}); + + +test('extend', function () { + $schema = Expect::structure([Expect::string(), Expect::string()]); + + Assert::equal( + Expect::structure([Expect::string(), Expect::string(), Expect::int()]), + $schema->extend([Expect::int()]), + ); + + Assert::equal( + Expect::structure([Expect::string(), Expect::string(), Expect::int()]), + $schema->extend(Expect::structure([Expect::int()])), + ); +}); + + +test('getShape', function () { + Assert::equal( + [Expect::int(), Expect::string()], + Expect::tuple([Expect::int(), Expect::string()])->getShape(), + ); +}); From aaf5bb6353759c8c0af6b9a7603ac0a940b3a14b Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 14 Jul 2026 05:35:20 +0200 Subject: [PATCH 17/21] DOCS --- AGENTS.md | 18 +++++-- docs/internals.md | 115 ++++++++++++++++++++++++++++++------------ docs/migration-2.0.md | 56 ++++++++++++++++++++ 3 files changed, 152 insertions(+), 37 deletions(-) create mode 100644 docs/migration-2.0.md diff --git a/AGENTS.md b/AGENTS.md index 63be10f..4cfbed7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ subtler than it looks. inputs) through a fluent `Expect::` builder and a `Processor`. - **PHP Version**: 8.1 - 8.5 -- **Package**: `nette/schema` (dep: `nette/utils`) +- **Package**: `nette/schema` (dep: `nette/utils`); `master` = 2.0-dev, + maintenance lives on `v1.x` branches ## Essential Commands @@ -36,6 +37,7 @@ composer phpstan - Every file starts with `declare(strict_types=1);`; **tabs**; single quotes; `@internal` for implementation details, `@method` for `Expect`'s magic methods; + deprecations use the native `#[\Deprecated]` attribute, not phpDoc; Nette Coding Standard. - Tests are Nette Tester `.phpt` named `Expect..phpt`; `checkValidationErrors()` asserts the expected error messages of a failing `process()`. @@ -54,9 +56,17 @@ composer phpstan `complete()` is an `$isOk = $context->createChecker(); $isOk() && nextStep()` short-circuit chain - thread any new validation step through the checker or it runs on already-rejected values. -- **`PreventMerging` (`'_prevent_merging'`) is in-band control metadata** injected - into the data and stripped-and-honored differently in ~5 places (Type/Structure/ - AnyOf/Helpers). Any new element must reproduce the dance or merging misbehaves. +- **Merging is schema-driven** (2.0): `Schema::merge()` takes a `Context`, + strategy resolves as `mergeWith(closure)` → `MergeMode` (`mergeMode()`) → + recursion **only through item schemas**. Ambiguous merges (colliding arrays + with no schema guidance) add a `Message::CannotMerge` error, never a silent + guess. `AnyOf` probes which alternative both layers match + (`Context::isPartial` = validation-only completion) and delegates to it. +- **`PreventMerging` (`'_prevent_merging'`) was removed** (BC break) — + `Processor::rejectPreventMerging()` reports the key as an error; use + `mergeMode(MergeMode::Replace)` instead. +- **Defaults are not merged into supplied arrays** (`Type::$merge = false`; + `mergeDefaults()` is deprecated) - a partial input array stays partial. - **`assert`/`castTo` are sugar over `transform`** - one `$transforms` list running in declaration order, so `->assert()->castTo()` differs from `->castTo()->assert()`. - **`default` null is not `nullable`** (`nullable()` prepends `'null|'` to the type diff --git a/docs/internals.md b/docs/internals.md index 20756be..bf3bfdf 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -16,7 +16,7 @@ per run: left-to-right, then a single `complete()`. **Validation is not a separate step — it happens inside `complete()`.** Type -checking, range, pattern, item recursion, default merging, and transforms all run +checking, range, pattern, item recursion, and transforms all run there (`Type::complete`). `merge()` is reached **only** through `processMultiple`. `completeDefault()` runs for items missing from the input. Reading the interface as "normalize / validate / complete" (as older docs do) @@ -67,25 +67,63 @@ before it — that is how `%path%` disappears at the root. Codes are the placeholder with no matching variable triggers an undefined-array-key warning in `toString()`, so keep template and variables in sync. -## `PreventMerging`: in-band metadata, handled in many places - -The magic array key `Helpers::PreventMerging` (`'_prevent_merging'`) is -injected **directly into the data** to mean "replace, don't merge with the base / -default". Because it rides inside the value, **every element must detect and strip -it** — and they do so in subtly different ways: - -- `Type::normalize` strips it, then **re-adds** it after recursing into items (so - it survives normalization). -- `Type::complete` strips it and forces `$merge = false` (default not merged in). -- `Type::merge` / `AnyOf::merge` / `Helpers::merge` strip it and return the value - as-is (no merge). -- `Structure::merge` strips it and sets `$base = null` (full replace). - -This is the package's sharpest trap: a piece of control state travelling through -the payload, replicated across five sites. Any new `Schema` element must reproduce -the strip-and-honor dance or merging silently misbehaves. (There is a standing -idea to replace it with a declarative `MergeMode::Replace`; DI carries its own -parallel `PREVENT_MERGING` constant. See `docs/local/ideas/odstranit-prevent-merging.md`.) +## Schema-driven merging (2.0) + +`Schema::merge(mixed $value, mixed $base, Context $context)` combines two +normalized layers, `$value` (later, higher priority) over `$base`. Errors +accumulate in the Context like everywhere else (`Processor::processMultiple` +throws after each merge, before `complete()`), and recursion maintains +`$context->path`, so merge errors carry a path. + +Every element resolves its strategy in the same order: + +1. **`mergeWith(closure)`** (`Base`) wins outright — a user-supplied **pure + combiner** `fn($value, $base): mixed`. It runs only *between* layers (n−1 + times; the sole layer of a single-layer dataset never passes through it), so + it must combine, never canonicalize shape — that belongs to `before()`. + Legitimate for scalars too (bool OR, max, concatenation) and doubles as the + escape hatch for blind deep merge of free-form trees. +2. **`MergeMode`** (`mergeMode()`, internal state `null` = unspecified): + `Replace` returns `$value` wholesale; `OverwriteKeys` merges by keys with + numeric keys overwritten positionally; `AppendKeys` additionally appends + new numeric elements. Defaults: `Type` → `AppendKeys`; `Structure` → + `AppendKeys` with `otherItems`, else `OverwriteKeys`. +3. **Recursion follows the schema only** — `Type` through `itemsValue`, + `Structure` through `items[key] ?? otherItems`. A colliding key whose both + sides are arrays but whose schema gives no guidance (no items schema, no + explicit `mergeMode()`) adds a **`Message::CannotMerge` error** instead of + silently picking a depth — explicit `mergeMode()` is the declared opt-out + (colliding value then overwrites). Scalar collisions overwrite silently. +4. **Null rule (uniform):** a `null` layer value loses to an array and beats + a scalar (`$value === null && is_array($base) ? $base : $value`) — NEON + `key:` means "no opinion" against arrays. + +**`AnyOf::merge` probes instead of merging blindly:** it finds the first +variant (declaration order) that **both** layers match and delegates to its +`merge()`. Matching runs each layer through `normalize` + `complete` in a +throwaway Context with **`Context::isPartial`** set — a validation-only mode +where `completeDefault` doesn't report missing required items (a layer is +legally partial), `doTransform` is skipped (a `castTo` constructor would +crash on a partial layer), and deprecations stay silent. No common variant: +two arrays → `CannotMerge` error; otherwise the later value wins (scalar +`proxy: string|array` overrides keep working). `DynamicParameter` on either +side → plain replace. **Known limitation:** the probe matches layers through +the variant's `normalize()`, but delegation merges the AnyOf-level values — +a variant whose `before()` reshapes layers therefore merges as plain replace +(v1-compatible). Re-normalizing for the merge is not an option: `complete()` +would then run the variant's `before()` a second time on the merged result. + +## `PreventMerging` is gone; transitional guard + +The v1 magic key `'_prevent_merging'` (in-band metadata meaning "replace, +don't merge") was **removed entirely** — no constant, no `Helpers::merge()`, +nothing strips it from data. So it doesn't silently flow into output as +ordinary data, `Processor::rejectPreventMerging()` recursively scans every +dataset before normalization and reports the key as a `CannotMerge` error; +the declarative replacement is `mergeMode(MergeMode::Replace)`, the NEON +`key!:` syntax is DI's job (dropping the key from earlier layers before +`processMultiple`). DI still carries its own parallel `PREVENT_MERGING` +constant and merge for `includes` handling. ## One transform pipeline; `assert`/`castTo` are sugar over `transform` @@ -109,6 +147,11 @@ one `doTransform` pass, after type/range/pattern validation. Reordering from an empty array". The check is **unconditional — it fires even after `nullable()`**, so a nullable array-typed item never yields `null`, and a NEON key written bare (`key:`) validates as an empty array. +- **Defaults are not merged into supplied arrays** (2.0 BC break): `Type::$merge` + defaults to `false`, so a partially supplied array no longer gets the default's + keys merged underneath it. `mergeDefaults()` still works but is + `#[\Deprecated]` and emits `E_USER_DEPRECATED` when enabling; its blind deep + merge lives on only as private `Type::deepMerge()`. ## Keys validate like values — and collapse on failure @@ -164,10 +207,7 @@ by validating dynamics eagerly. - **`processMultiple` merges left-value-wins:** each later dataset item is the `value` (higher priority) merged over the accumulated `base`, so later configs - override earlier ones. Numeric-keyed items append; string-keyed recurse. -- **`Structure::merge` appends numeric keys only when `otherItems` is set** - (`$index = $this->otherItems === null ? null : 0`); `Type::merge` and - `Helpers::merge` always append numeric-keyed items. + override earlier ones (details in "Schema-driven merging" above). - **`castTo` forks by target** (`Helpers::getCastStrategy`): builtin → `settype`; class **with** constructor → named args from the array/stdClass (a scalar is passed as a single argument); anything else → property assignment @@ -181,13 +221,20 @@ by validating dynamics eagerly. ## `Expect::from()` mapping rules -`Expect::from($object)` reflects **constructor parameters if `__construct` -exists, otherwise properties** — a class with a constructor has its properties -ignored entirely. Per item: uninitialized property / non-optional parameter → -`required()`; a `null` default on a type that does not accept null → also -`required()` (not "default null"); an **object** default recurses into a nested -`from()`; anything else becomes `default($def)`. The type comes from -`Helpers::getPropertyType` (native type, then `@var`), falling back to `mixed`. +`Expect::from()` accepts an instance **or a class name** (since 2.0) and +reflects **constructor parameters if `__construct` exists, otherwise +properties** — a class with a constructor has its properties ignored entirely. +Types come from **native declarations only** (`Nette\Utils\Type::fromReflection`, +fallback `mixed`); phpDoc `@var` support was removed in 2.0. Per item: + +- a non-nullable class-typed item recurses into `from($thatClass)` **even + without a default** (beware: `class_exists` is also true for enums, which + then map badly); +- no default (uninitialized property / non-optional parameter) → `required()`; +- an **object** default recurses into a nested `from($default)` (instance-based); +- any other default — including `null` — becomes `default($def)` (the 1.x rule + "null default on a non-nullable type → `required()`" is gone). + The result is a `Structure` with `castTo($class)` **stacked after** the constructor's built-in `castTo('object')`, so a completed value travels array → `stdClass` → instance through the cast fork above. @@ -232,7 +279,9 @@ Everything here is `@internal` so the vocabulary can still change. |---|---| | Entry points, phase order | `Processor::process`, `processMultiple` | | Error accumulation, checker idiom | `Context`, every `Elements/*::complete` | -| `PreventMerging` handling | `Helpers::merge`, `Type`/`Structure`/`AnyOf` normalize/merge/complete | +| Merge strategies | `MergeMode`, `Base::mergeMode`/`mergeWith`, every `Elements/*::merge` | +| AnyOf probe, partial mode | `AnyOf::matches`, `Context::isPartial` | +| `_prevent_merging` guard | `Processor::rejectPreventMerging` | | Transform/assert/castTo pipeline | `Base` (`transforms`, `doTransform`, `assert`, `castTo`) | | Type validation & null/dynamic | `Type::complete`, `Helpers::validateType` | | Structure object output, defaults | `Structure` (`completeDefault`, `validateItems`) | @@ -241,5 +290,5 @@ Everything here is `@internal` so the vocabulary can still change. | DI / integration hook | `Processor::onNewContext`, `createContext` | | Error message rendering | `Message::toString`, `Message::*` code constants | | Key schemas, `isKey` | `Type::normalize`/`validateItems`, `Context::isKey` | -| Object-to-schema mapping | `Expect::from`, `Helpers::getPropertyType` | +| Object-to-schema mapping | `Expect::from` (native types only) | | Inspection, JSON Schema export | `Elements/*::describe`, `Kind`, `TypeExpression::parse`, `JsonSchema::export` | diff --git a/docs/migration-2.0.md b/docs/migration-2.0.md new file mode 100644 index 0000000..cdee672 --- /dev/null +++ b/docs/migration-2.0.md @@ -0,0 +1,56 @@ +# Migrating to Schema 2.0 + +Guiding principle of 2.0: where behavior had to change, you get **an exception +or error instead of silently different results**. This guide lists every BC +break and its remedy. + +## Merging of configuration layers (processMultiple) + +Merging is now **driven by the schema**, not by blind array mechanics. + +- **`Schema::merge()` signature changed** to + `merge(mixed $value, mixed $base, Context $context): mixed`. Custom `Schema` + implementations must add the parameter (and the return type hints added + across the interface). +- **Ambiguous merges fail loudly.** When two layers collide on a key holding + arrays on both sides and the schema does not describe the items, processing + fails with *"Cannot merge …"* instead of silently deep-merging (v1) or + overwriting. Remedies: + - describe the data: `Expect::arrayOf(...)`, `Expect::structure(...)`, + `otherItems()`; + - or declare the strategy: `->mergeMode(MergeMode::AppendKeys)` (v1-like), + `OverwriteKeys`, or `Replace`; + - or supply a custom combiner: `->mergeWith(fn($value, $base) => ...)` — + also the way to get a blind deep merge back if you really want it. +- **`AnyOf` no longer merges blindly**: layers merge according to the + alternative they both match; layers matching different alternatives fail + with an error when both are arrays (a scalar layer still simply replaces). + This fixes nette/database#223-class bugs. +- **The `'_prevent_merging'` magic key was removed.** Data containing it is + rejected with an error. Use `->mergeMode(MergeMode::Replace)` in the schema; + the NEON `key!:` syntax is handled by nette/di. + +## Defaults are not merged into supplied arrays + +`Type::$merge` defaults to `false`: a partially supplied array stays partial, +the default's keys are no longer merged underneath it. `mergeDefaults()` still +works but is deprecated and will be removed in the next major version. + +## Removed APIs + +- `Helpers::merge()` and `Helpers::PreventMerging` (both were `@internal`). + +## Other 2.0 changes (pre-dating the merge overhaul) + +- `Schema` interface methods have native return type hints. +- `Expect::from()` reads **native property types only** (phpDoc `@var` support + removed) and accepts a class name in addition to an instance; a `null` + default on a non-nullable type is now `default(null)`, not `required()`. + +## New features + +- `MergeMode` enum + `mergeMode()` on all elements. +- `mergeWith(callable)`: custom merge strategy (a pure combiner — it runs only + between layers; canonicalize a layer's shape in `before()` instead). +- `Expect::tuple([...])`: fixed-size array with per-position schemas; layers + replace the tuple wholesale. From c077364f08fb20a526fca335feddc58e05f60b19 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 07:23:02 +0200 Subject: [PATCH 18/21] added Expect::listable() --- src/Schema/Expect.php | 13 ++++++- tests/Schema/Expect.listable.phpt | 61 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/Schema/Expect.listable.phpt diff --git a/src/Schema/Expect.php b/src/Schema/Expect.php index fb22af1..00bc478 100644 --- a/src/Schema/Expect.php +++ b/src/Schema/Expect.php @@ -11,7 +11,7 @@ use Nette\Schema\Elements\AnyOf; use Nette\Schema\Elements\Structure; use Nette\Schema\Elements\Type; -use function is_object; +use function array_is_list, is_array, is_object, is_subclass_of; /** @@ -164,4 +164,15 @@ public static function tuple(array $shape): Structure return (new Structure($shape))->castTo('array')->mergeMode(MergeMode::Replace); } + + + /** + * Creates a list where a single value is also accepted and normalized to a one-element list. + */ + public static function listable(string|Schema $type): Type + { + return (new Type('list')) + ->items($type) + ->before(fn($value) => is_array($value) || $value === null ? $value : [$value]); + } } diff --git a/tests/Schema/Expect.listable.phpt b/tests/Schema/Expect.listable.phpt new file mode 100644 index 0000000..e73f411 --- /dev/null +++ b/tests/Schema/Expect.listable.phpt @@ -0,0 +1,61 @@ +process($schema, 'a')); + Assert::same(['a', 'b'], (new Processor)->process($schema, ['a', 'b'])); + Assert::same([], (new Processor)->process($schema, [])); + Assert::same([], (new Processor)->process($schema, null)); +}); + + +test('items are validated', function () { + $schema = Expect::listable('string'); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, 123); + }, ["The item '0' expects to be string, 123 given."]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, ['a', 123]); + }, ["The item '1' expects to be string, 123 given."]); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, ['key' => 'val']); + }, ['The item expects to be list, array given.']); +}); + + +test('item can be a schema', function () { + $schema = Expect::listable(Expect::int()->min(1)); + + Assert::same([5], (new Processor)->process($schema, 5)); + + checkValidationErrors(function () use ($schema) { + (new Processor)->process($schema, 0); + }, ["The item '0' expects to be in range 1.., 0 given."]); +}); + + +test('layers merge as lists', function () { + $schema = Expect::structure([ + 'emails' => Expect::listable('string'), + ]); + + Assert::equal( + (object) ['emails' => ['a', 'b', 'c']], + (new Processor)->processMultiple($schema, [ + ['emails' => 'a'], + ['emails' => ['b', 'c']], + ]), + ); +}); From 078bf666b7b93484e03ccfcfa7692854b4c864a5 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 07:23:15 +0200 Subject: [PATCH 19/21] added Expect::enum() --- src/Schema/Expect.php | 15 +++++++ src/Schema/Helpers.php | 2 +- tests/Schema/Expect.enum.phpt | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/Schema/Expect.php b/src/Schema/Expect.php index 00bc478..cbd535c 100644 --- a/src/Schema/Expect.php +++ b/src/Schema/Expect.php @@ -175,4 +175,19 @@ public static function listable(string|Schema $type): Type ->items($type) ->before(fn($value) => is_array($value) || $value === null ? $value : [$value]); } + + + /** + * Creates a schema for a backed enum case, given as the case itself or its backing value. + * @param class-string<\BackedEnum> $class + */ + public static function enum(string $class): Type + { + if (!is_subclass_of($class, \BackedEnum::class)) { + throw new Nette\InvalidArgumentException("Class '$class' is not a backed enum."); + } + + $backing = (string) (new \ReflectionEnum($class))->getBackingType(); + return (new Type("$backing|$class"))->castTo($class); + } } diff --git a/src/Schema/Helpers.php b/src/Schema/Helpers.php index 7869660..00dd838 100644 --- a/src/Schema/Helpers.php +++ b/src/Schema/Helpers.php @@ -125,7 +125,7 @@ public static function getCastStrategy(string $type): \Closure } elseif (is_subclass_of($type, \BackedEnum::class)) { return static function ($value, Context $context) use ($type) { try { - return $type::from($value); + return $value === null || $value instanceof $type ? $value : $type::from($value); } catch (\TypeError | \ValueError) { $context->addError( 'The %label% %path% expects to be %expected%, %value% given.', diff --git a/tests/Schema/Expect.enum.phpt b/tests/Schema/Expect.enum.phpt index 38e0726..fdaed09 100644 --- a/tests/Schema/Expect.enum.phpt +++ b/tests/Schema/Expect.enum.phpt @@ -25,3 +25,79 @@ test('unit enum as standalone type', function () { (new Processor)->process($schema, 'Clubs'); }, ['The item expects to be Suit, \'Clubs\' given.']); }); + + +enum Color: string +{ + case Red = 'red'; + case Blue = 'blue'; +} + +enum Level: int +{ + case Low = 1; + case High = 2; +} + + +test('backing value is cast to a case', function () { + Assert::same(Color::Red, (new Processor)->process(Expect::enum(Color::class), 'red')); + Assert::same(Level::High, (new Processor)->process(Expect::enum(Level::class), 2)); +}); + + +test('case instance passes through', function () { + Assert::same(Color::Blue, (new Processor)->process(Expect::enum(Color::class), Color::Blue)); +}); + + +test('invalid value lists the allowed ones', function () { + checkValidationErrors(function () { + (new Processor)->process(Expect::enum(Color::class), 'green'); + }, ["The item expects to be 'red'|'blue', 'green' given."]); + + checkValidationErrors(function () { + (new Processor)->process(Expect::enum(Level::class), 3); + }, ['The item expects to be 1|2, 3 given.']); +}); + + +test('wrong type is rejected before casting', function () { + checkValidationErrors(function () { + (new Processor)->process(Expect::enum(Color::class), []); + }, ['The item expects to be string or Color, array given.']); +}); + + +test('default and nullable', function () { + $schema = Expect::structure([ + 'color' => Expect::enum(Color::class)->default(Color::Red), + 'level' => Expect::enum(Level::class)->nullable(), + ]); + + Assert::equal( + (object) ['color' => Color::Red, 'level' => null], + (new Processor)->process($schema, []), + ); + + Assert::equal( + (object) ['color' => Color::Blue, 'level' => null], + (new Processor)->process($schema, ['color' => 'blue', 'level' => null]), + ); +}); + + +testException( + 'pure enum is rejected', + fn() => Expect::enum(Suit::class), + Nette\InvalidArgumentException::class, + "Class 'Suit' is not a backed enum.", +); + + +testException( + 'ordinary class is rejected', + fn() => Expect::enum(stdClass::class), + Nette\InvalidArgumentException::class, + "Class 'stdClass' is not a backed enum.", +); From 0c790a237788ef10187a8adc180987aee1e09f76 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 07:23:15 +0200 Subject: [PATCH 20/21] DOCS --- docs/internals.md | 14 ++++++++------ docs/migration-2.0.md | 4 ++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/internals.md b/docs/internals.md index bf3bfdf..39ed31a 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -209,12 +209,14 @@ by validating dynamics eagerly. `value` (higher priority) merged over the accumulated `base`, so later configs override earlier ones (details in "Schema-driven merging" above). - **`castTo` forks by target** (`Helpers::getCastStrategy`): builtin → - `settype`; class **with** constructor → named args from the array/stdClass - (a scalar is passed as a single argument); anything else → property assignment - via `Arrays::toObject((array) $value, new $type)`. There is **no enum branch**: - an enum has no constructor, falls into the `new $type` path and dies with a - PHP `Error`. This fork is the mechanism behind both `castTo(Class::class)` - and Structure's object output. + `settype`; **backed enum** → `::from()` (null and a ready instance pass + through; an invalid value adds a `TypeMismatch` error listing the allowed + backing values; a pure enum throws `InvalidStateException` at schema build + time); class **with** constructor → named args from the array/stdClass + (a scalar is passed as a single argument); anything else → property + assignment via `Arrays::toObject((array) $value, new $type)`. This fork is + the mechanism behind `castTo(Class::class)`, `Expect::enum()` and + Structure's object output. - **`min`/`max` mean different things by type** (`validateRange`): item count for arrays, character length (`unicode` type) or byte length (otherwise) for strings, the value itself for numbers. diff --git a/docs/migration-2.0.md b/docs/migration-2.0.md index cdee672..a617e57 100644 --- a/docs/migration-2.0.md +++ b/docs/migration-2.0.md @@ -54,3 +54,7 @@ works but is deprecated and will be removed in the next major version. between layers; canonicalize a layer's shape in `before()` instead). - `Expect::tuple([...])`: fixed-size array with per-position schemas; layers replace the tuple wholesale. +- `Expect::listable(type)`: accepts a single value or a list of values, + normalizes to a list — and layers therefore merge by appending. +- `Expect::enum(SomeBackedEnum::class)`: accepts a case or its backing value, + yields the case instance; `castTo()` now supports backed enums in general. From 5997c96b8d43f91720db7b309b219ee83e3b9e6a Mon Sep 17 00:00:00 2001 From: Jenthe Noordsij Date: Wed, 26 Aug 2026 17:14:37 +0200 Subject: [PATCH 21/21] Add support for PHP 8.6 --- .github/workflows/tests.yml | 2 +- AGENTS.md | 2 +- composer.json | 2 +- readme.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 49764a8..0ba6d84 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + php: ['8.1', '8.2', '8.3', '8.4', '8.5', '8.6'] fail-fast: false diff --git a/AGENTS.md b/AGENTS.md index 4cfbed7..1517624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ subtler than it looks. **Nette Schema** validates and normalizes data structures (config files, API inputs) through a fluent `Expect::` builder and a `Processor`. -- **PHP Version**: 8.1 - 8.5 +- **PHP Version**: 8.1 - 8.6 - **Package**: `nette/schema` (dep: `nette/utils`); `master` = 2.0-dev, maintenance lives on `v1.x` branches diff --git a/composer.json b/composer.json index c08148e..fd7b1aa 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "8.1 - 8.5", + "php": "8.1 - 8.6", "nette/utils": "^4.0" }, "require-dev": { diff --git a/readme.md b/readme.md index 356a327..d47f87b 100644 --- a/readme.md +++ b/readme.md @@ -20,7 +20,7 @@ Installation: composer require nette/schema ``` -It requires PHP version 8.1 and supports PHP up to 8.5. +It requires PHP version 8.1 and supports PHP up to 8.6. [Support Me](https://github.com/sponsors/dg)