Skip to content

Commit ecb776e

Browse files
authored
feat(runtime): Python validator runner and enhanced validation enforcement (#402)
* feat(runtime-ts): runValidators enforces the validation-conformance corpus runValidators now runs fixtures/validation-conformance and passes it. New run-time rules: validator.numeric and validator.array bounds, the strict field.uri / field.inet format contract (@lenient opts out), and presence of an assigned primary key. Two existing rules are corrected to match the generated Zod schema: @maxlength x validator.length @max is strictest-wins, and an authored validator.length @min overrides the required-string floor. runtime-errors.json pins the exact failure list per rejected case so a second run-time runner can be held to the same structure and message text. runValidators is exported from the package root. * feat(python): run-time validator runner, run_validators metaobjects.runtime.run_validators validates a data mapping against an entity's metadata: the Python port of the TypeScript runValidators, with the same rules, failure structure, message text and ordering. It never raises. ObjectManager.validate() returns the same result for a loaded entity. The runner runs fixtures/validation-conformance and asserts the failure list pinned in runtime-errors.json, the same file the TypeScript runner asserts. Docs: docs/ports/python.md, the corpus README, docs/CONFORMANCE.md (the case count was stale at 16; the corpus has 42), and CHANGELOG, which records the TypeScript behaviour change under Changed. * fix(runtime): close two parity gaps between the run-time validator runners A package-qualified @objectref on a value-object field resolved by bare name in the TypeScript runner, so with two same-named value objects in different packages it validated against the first one declared. It now matches the package-qualified key first, as the Python runner does. The Python runner printed a float in a failure message as Python does (1e-05, inf). It now follows ECMAScript Number::toString (0.00001, Infinity), so message text is identical to the TypeScript runner for every value. Both found by the independent branch review. * no-mistakes(review): resolve VO @objectref via canonical ADR-0042 resolver in both runners
1 parent 3dc33c3 commit ecb776e

18 files changed

Lines changed: 1940 additions & 37 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ it until 1.1 ships._
1818

1919
### Added
2020

21+
- **Python: a run-time validator runner, `run_validators`.** `metaobjects.runtime.run_validators(entity, data)`
22+
validates a data mapping against an entity's metadata with no generated code and no database,
23+
and `ObjectManager.validate(entity_name, data)` does the same for a loaded entity. It is the
24+
port of TypeScript's `runValidators`: it never raises, collects every failure as
25+
`{field, rule, message, expected, received}`, and uses the same rules and message text. Both
26+
runners now run `fixtures/validation-conformance/`, and the new `runtime-errors.json` there
27+
pins the exact failure list each must produce. See `docs/ports/python.md`, "Run-time
28+
validation".
29+
- **TypeScript: `runValidators` is exported from `@metaobjectsdev/runtime-ts`**, with
30+
`RunValidatorsOpts`. It was reachable only through `ObjectManager.validate()` before.
31+
2132
- **Metamodel 1.1: the reporting vocabulary (FR-044), loader-validated in all five ports.**
2233
Registered: `dimension.attribute`, `dimension.time` (`@grains`: `hour, day, week, month,
2334
quarter, year`, weeks start Monday), `measure.aggregate` (`@agg`: `count, sum, avg, min, max`),
@@ -62,6 +73,32 @@ it until 1.1 ships._
6273
(`executeQuery`) with a report as its result class builds rows from the report's derived
6374
fields.
6475

76+
### Changed
77+
78+
- **TypeScript: `runValidators` rejects more than it did — a behaviour change for
79+
`ObjectManager` users.** `ObjectManager.create`, `createMany`, `update`, `updateMany` and
80+
`validate` all go through it, so data that was accepted before can now raise a
81+
`ValidationError`. The run-time runner had fallen behind the generated Zod schema; it now
82+
passes the same `validation-conformance` corpus. What is newly enforced:
83+
- `validator.numeric @min`/`@max` on `field.int`, `long`, `currency`, `double` and `float`
84+
(rule `numeric`). These bounds were ignored at run time.
85+
- `validator.array @min`/`@max` on an array field's element count (rule `array`). Also
86+
ignored before.
87+
- `field.uri` must be an absolute URI and `field.inet` an IPv4 or IPv6 literal (rule
88+
`format`); a non-string value for either is a `type` failure. `@lenient: true` opts out
89+
of the format check.
90+
- An assigned primary key (no `@generation: increment` or `uuid`, no `@default`) is
91+
`required` on insert. `create` already refused this; `validate()` now reports it too.
92+
- `@maxLength` and `validator.length @max` on one field are strictest-wins. The runner
93+
used `@maxLength` alone, so a tighter validator bound was not applied.
94+
95+
One resolution is corrected: a package-qualified `@objectRef` (`billing::Address`) on a
96+
value-object field now resolves to the object in that package. It resolved to the first
97+
object of that bare name, so with two same-named value objects the wrong one's rules ran.
98+
99+
One rule is relaxed: an authored `validator.length @min: 0` on a `@required` string now
100+
admits the empty string, as the generated schema already did.
101+
65102
### Fixed
66103

67104
- **Python, C#, Java: the generic `view.*` controls now load.** A document carrying

‎docs/CONFORMANCE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
3535
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
3636
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 39 (33 query + 6 migration) | all 39 | 33 query (migrations TS-only, ADR-0015) | 33 query (via Exposed) | 33 query | 33 query |
3737
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 61 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 7 projection) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) |
38-
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 16 cases | ✓ | ✓ | ✓ | ✓ | ✓ |
38+
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
3939
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
4040
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
4141
| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 4 | ✓ | ✓ | ✓ | ✓ | ✓ |

‎docs/ports/python.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,52 @@ name_field = [f for f in author.children() if f.name == "name"][0]
437437
print(name_field.get_meta_attr("maxLength")) # -> 200
438438
```
439439

440+
### Run-time validation
441+
442+
`run_validators` checks a data mapping against an entity's metadata, with no generated
443+
code and no database. It is the Python port of TypeScript's `runValidators`: the same
444+
rules, the same failure structure and the same message text.
445+
446+
```python
447+
from metaobjects.runtime import run_validators
448+
449+
account = next(c for c in result.root.children() if c.name == "Account")
450+
outcome = run_validators(account, {"name": "", "score": 101})
451+
outcome.ok # -> False
452+
[e.to_dict() for e in outcome.errors]
453+
# [{"field": "name", "rule": "length", "message": "'name' must be at least 1 chars (got 0)",
454+
# "expected": {"min": 1}, "received": 0},
455+
# {"field": "score", "rule": "numeric", "message": "'score' must be at most 100 (got 101)",
456+
# "expected": {"max": 100}, "received": 101}]
457+
```
458+
459+
It never raises. Every failure on every field is collected; a `required` or `type`
460+
failure stops further checks on that one value only. The rules, by `rule` name:
461+
462+
| `rule` | Fires when |
463+
|---|---|
464+
| `required` | a `@required` field, a field with `validator.required`, or an assigned primary key (no `@generation: increment` or `uuid`) is absent or `None` |
465+
| `type` | the value is not the field subtype's type; an array field is not a list; a value-object field is not a mapping |
466+
| `length` | a string is longer than `min(@maxLength, validator.length @max)` or shorter than `validator.length @min`. A `@required` string has a floor of 1 unless a `@min` is authored |
467+
| `regex` | a string does not fully match `validator.regex @pattern` |
468+
| `numeric` | a number is outside `validator.numeric @min`/`@max` (inclusive) |
469+
| `array` | an array's element count is outside `validator.array @min`/`@max` |
470+
| `format` | a `field.uri` is not an absolute URI, or a `field.inet` is not an IPv4/IPv6 literal. `@lenient: true` opts out |
471+
472+
Keyword options: `partial=True` is update mode, where an absent key is untouched and
473+
only present keys are checked. `store_filled=[...]` names fields the store fills on
474+
insert, which are then exempt from `required` when absent. A field with a `@default` is
475+
also exempt when absent. A value object is validated in full, and its failures are
476+
labelled `field.member` or `field[i].member`.
477+
478+
`ObjectManager.validate(entity_name, data)` returns the same result for a loaded entity.
479+
The `ObjectManager` write methods do not call it, so validate first when the data is
480+
untrusted.
481+
482+
Three details follow JavaScript so that both runners report identical failures: string
483+
length counts UTF-16 code units, a `bool` is not accepted as a number, and a number in a
484+
message prints as JavaScript prints it (`2.0` as `2`, `1e-07` as `1e-7`).
485+
440486
## FR-004 — render
441487

442488
`render` takes a `RenderRequest` (only `payload` + `provider` are required; `ref`
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Python Run-time Validator Runner Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Give the Python port a run-time validator runner equal to TypeScript's `runValidators`, and make both runners pass `fixtures/validation-conformance/`.
6+
7+
**Architecture:** One pure function per port — it never raises, it collects every failure as `{field, rule, message, expected?, received?}`. TypeScript's `runValidators` is the contract. The corpus gates boolean verdicts; a new sibling file `runtime-errors.json` pins the exact failure list so the two runners cannot drift in structure or message text.
8+
9+
**Tech Stack:** Python 3 standard library only (`re`), pytest; TypeScript, `bun test`.
10+
11+
**Spec:** no separate spec. The contract is `server/typescript/packages/runtime-ts/src/validator-runner.ts` plus `fixtures/validation-conformance/README.md`.
12+
13+
## Global Constraints
14+
15+
- No new vocabulary, no new validator subtype, no metamodel change. Java is not touched.
16+
- The Python runner adds no runtime dependency (`PyYAML` stays the only one).
17+
- ADR-0039: read effective values. Python `attr()` is OWN-only — use `get_meta_attr()` / `children()`. The one own read is `@dbColumnType`.
18+
- Rules, rule names, field labels and message text are byte-identical in both runners.
19+
- The behaviour change for TypeScript users goes in `CHANGELOG.md` under `[Unreleased]` (the 1.1 line).
20+
21+
## The rules both runners implement
22+
23+
Per field, in declaration order (effective children):
24+
25+
1. **required** — `mustBePresent = @required | validator.required | assigned primary key with no @default`. An assigned primary key is a field of the primary identity whose `@generation` is neither `increment` nor `uuid`. Absent or null → `{rule: "required", message: "'f' is required"}`. Existing exemptions are unchanged (`partial` and absent; `@default` and absent; `storeFilled` and absent).
26+
2. Open-bag jsonb string, value-object recursion: unchanged.
27+
3. **array** (new) — on an array field, `validator.array @min/@max` bounds the element count:
28+
`{rule: "array", message: "'tags' must have at least 1 items (got 0)", expected: {min: 1}, received: 0}` and `"... at most 3 items (got 4)"` with `expected: {max: 3}`. Applies to scalar arrays and value-object arrays.
29+
4. **type** — unchanged, plus `field.uri` / `field.inet` must be a string (`expected string`).
30+
5. **length** — max is strictest-wins: `min(@maxLength, validator.length @max)`. Min is the authored `validator.length @min` when one is authored (`@min: 0` opts out of the floor), else 1 for a declared-required string, else 0. Messages unchanged. Length counts UTF-16 code units in both ports.
31+
6. **regex** — unchanged (full match).
32+
7. **numeric** (new) — on `field.int|long|currency|double|float`, `validator.numeric @min/@max`, inclusive:
33+
`{rule: "numeric", message: "'score' must be at least 0 (got -1)", expected: {min: 0}, received: -1}` and `"... at most 100 (got 101)"` with `expected: {max: 100}`. An int64 passed as a numeric string is compared as an integer and echoed as given.
34+
8. **format** (new) — unless `@lenient: true`:
35+
- `field.uri`: strip leading/trailing characters ≤ U+0020; the rest must match `^[A-Za-z][A-Za-z0-9+.-]*:` with a non-empty remainder, and when the remainder starts with `//` the authority (up to the next `/`, `?` or `#`) must be non-empty. Failure: `{rule: "format", message: "'website' must be an absolute URI", expected: "uri", received: value}`.
36+
- `field.inet`: must match the IPv4 or IPv6 literal patterns from `codegen-ts/src/templates/net-regex.ts`. Failure: `{rule: "format", message: "'sourceIp' must be an IPv4 or IPv6 address", expected: "inet", received: value}`.
37+
38+
A type failure stops further checks on that value. Order of failures within one value: length max, length min, regex, numeric min, numeric max, format.
39+
40+
## Review Focus
41+
42+
- Python `bool` is an `int`: `True` on a numeric field must be a type failure, as in TypeScript.
43+
- A `1.0` bound must print as `1`, as JavaScript prints it.
44+
- A non-BMP character counts as 2 toward length in both ports.
45+
- An invalid `@pattern` yields a `regex` failure, never an exception.
46+
- `partial=True` with an absent assigned primary key is not a failure; a present `None` is.
47+
48+
---
49+
50+
### Task 1: Pin the failure list — `fixtures/validation-conformance/runtime-errors.json`
51+
52+
- [ ] Add `runtime-errors.json`: `{ "errors": { "<case name>": [ {field, rule, message, expected?, received?} ] } }`, one entry per `expectValid: false` case in `cases.json`.
53+
- [ ] Document the file and the two run-time runners in the corpus `README.md`.
54+
55+
### Task 2: TypeScript `runValidators` — corpus + new rules
56+
57+
**Files:** modify `server/typescript/packages/runtime-ts/src/validator-runner.ts`; tests in `server/typescript/packages/runtime-ts/test/validator-runner.test.ts`; create `server/typescript/packages/integration-tests/test/validation-conformance-runtime.test.ts`; modify `scripts/ci-local.sh` (`gate_conf_ts`).
58+
59+
- [ ] Write the corpus runner test: load `meta.json`, run each case through `runValidators`, assert `result.ok === expectValid`, and for a failing case assert `result.errors` deep-equals `runtime-errors.json`. Run it; expect the uri/inet/numeric/array/length/assigned-PK cases to fail.
60+
- [ ] Add unit tests for rules 1, 3, 5, 7, 8 and the Review Focus lines that apply to TypeScript. Run; expect failures.
61+
- [ ] Implement the rules. Run both test files; expect green. Run `bun test` in `runtime-ts` and `bun run --filter '*' typecheck`.
62+
- [ ] Commit.
63+
64+
### Task 3: Python `run_validators`
65+
66+
**Files:** create `server/python/src/metaobjects/runtime/validator_runner.py`; modify `server/python/src/metaobjects/runtime/__init__.py` and `object_manager.py` (`ObjectManager.validate`); tests `server/python/tests/runtime/test_validator_runner.py` and `server/python/tests/runtime/test_validation_conformance_runtime.py`.
67+
68+
**Interfaces:**
69+
70+
```python
71+
@dataclass(frozen=True)
72+
class ValidationFailure:
73+
field: str
74+
rule: str
75+
message: str
76+
expected: object = None
77+
received: object = None
78+
def to_dict(self) -> dict[str, object]: ... # omits expected/received when None
79+
80+
@dataclass(frozen=True)
81+
class ValidationResult:
82+
ok: bool
83+
errors: tuple[ValidationFailure, ...] = ()
84+
85+
def run_validators(entity: MetaData, data: Mapping[str, object], *,
86+
partial: bool = False, store_filled: Sequence[str] = ()) -> ValidationResult: ...
87+
88+
class ObjectManager:
89+
def validate(self, entity_name: str, data: Mapping[str, object]) -> ValidationResult: ...
90+
```
91+
92+
- [ ] Write the corpus runner test (same assertions as Task 2, `to_dict()` compared with `runtime-errors.json`) and the unit tests, porting `validator-runner.test.ts` case for case plus the Review Focus lines. Run; expect import failure.
93+
- [ ] Implement. Run `uv run pytest tests/runtime -q`, `uv run mypy`, `uv run ruff check`; expect green.
94+
- [ ] Commit.
95+
96+
### Task 4: Docs and changelog
97+
98+
- [ ] `docs/ports/python.md`: document `run_validators` and `ObjectManager.validate`.
99+
- [ ] `docs/CONFORMANCE.md`: correct the corpus case count and note the two run-time runners.
100+
- [ ] `CHANGELOG.md` `[Unreleased]`: Added (Python runner), Changed (TypeScript `runValidators` now enforces numeric, array, uri/inet format, assigned-PK presence, strictest-wins max length, authored `@min` over the floor — a behaviour change).
101+
- [ ] Commit.
102+
103+
### Task 5: Verify
104+
105+
- [ ] `scripts/ci-local.sh` (full) green; one independent review of the branch; fix findings.

‎fixtures/validation-conformance/README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ meta.json # `Account` (package acme::auth) exercising each constraint once,
1515
# plus `Ledger` — an ASSIGNED primary key (see below)
1616
cases.json # [{ name, entity?, payload, expectValid }] — single-source boolean verdicts
1717
# `entity` is optional and defaults to `Account`
18+
runtime-errors.json # the exact failure list per rejected case — run-time runners only (below)
1819
README.md
1920
```
2021

@@ -151,6 +152,27 @@ So the rule is:
151152
Python fuses both (Pydantic construct-or-`ValidationError`); the Java/Kotlin/C#
152153
runners wrap the bind step so a native-parse failure maps to `valid=false`.
153154

155+
## Run-time runners (TypeScript and Python)
156+
157+
Two ports also ship a metadata-driven **run-time** runner that needs no generated code:
158+
TypeScript `runValidators` (`@metaobjectsdev/runtime-ts`) and Python `run_validators`
159+
(`metaobjects.runtime`). Both run every case here and assert the same boolean verdict.
160+
161+
`runtime-errors.json` goes further for these two: for each rejected case it pins the
162+
exact failure list — `{ field, rule, message, expected?, received? }` — and both runners
163+
assert it by deep equality. That is what holds them to identical structure and message
164+
text, which a boolean verdict cannot. It applies to the run-time runners only; the
165+
generated artifacts report in their own native error shapes and stay on the boolean
166+
verdict.
167+
168+
The run-time rule for `field.uri` is an explicit pattern (a scheme, a non-empty
169+
remainder, and a non-empty authority after `//`), not a platform URL parser, so the two
170+
runners agree outside the pinned probe set too. It can therefore differ from the
171+
generated Zod `.url()` in the unpinned gray zone above.
172+
173+
Runners: `server/typescript/packages/integration-tests/test/validation-conformance-runtime.test.ts`
174+
and `server/python/tests/runtime/test_validation_conformance_runtime.py`.
175+
154176
## CI gate
155177

156178
All five port runners assert byte-identical boolean verdicts across all five

0 commit comments

Comments
 (0)