Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 15 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ 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
- **Package**: `nette/schema` (dep: `nette/utils`)
- **PHP Version**: 8.1 - 8.6
- **Package**: `nette/schema` (dep: `nette/utils`); `master` = 2.0-dev,
maintenance lives on `v1.x` branches

## Essential Commands

Expand All @@ -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.<feature>.phpt`; `checkValidationErrors()`
asserts the expected error messages of a failing `process()`.
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}
],
"require": {
"php": "8.1 - 8.5",
"php": "8.1 - 8.6",
"nette/utils": "^4.0"
},
"require-dev": {
Expand All @@ -38,7 +38,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
"dev-master": "2.0-dev"
}
},
"config": {
Expand Down
164 changes: 125 additions & 39 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`

Expand All @@ -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

Expand Down Expand Up @@ -164,41 +207,83 @@ 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
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.

## `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.

## 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 |
|---|---|
| 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`) |
Expand All @@ -207,4 +292,5 @@ array → `stdClass` → instance through the cast fork above.
| 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` |
60 changes: 60 additions & 0 deletions docs/migration-2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 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.
- `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.
Loading