Skip to content
Merged
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ it until 1.1 ships._

### Added

- **Python: a run-time validator runner, `run_validators`.** `metaobjects.runtime.run_validators(entity, data)`
validates a data mapping against an entity's metadata with no generated code and no database,
and `ObjectManager.validate(entity_name, data)` does the same for a loaded entity. It is the
port of TypeScript's `runValidators`: it never raises, collects every failure as
`{field, rule, message, expected, received}`, and uses the same rules and message text. Both
runners now run `fixtures/validation-conformance/`, and the new `runtime-errors.json` there
pins the exact failure list each must produce. See `docs/ports/python.md`, "Run-time
validation".
- **TypeScript: `runValidators` is exported from `@metaobjectsdev/runtime-ts`**, with
`RunValidatorsOpts`. It was reachable only through `ObjectManager.validate()` before.

- **Metamodel 1.1: the reporting vocabulary (FR-044), loader-validated in all five ports.**
Registered: `dimension.attribute`, `dimension.time` (`@grains`: `hour, day, week, month,
quarter, year`, weeks start Monday), `measure.aggregate` (`@agg`: `count, sum, avg, min, max`),
Expand Down Expand Up @@ -62,6 +73,32 @@ it until 1.1 ships._
(`executeQuery`) with a report as its result class builds rows from the report's derived
fields.

### Changed

- **TypeScript: `runValidators` rejects more than it did — a behaviour change for
`ObjectManager` users.** `ObjectManager.create`, `createMany`, `update`, `updateMany` and
`validate` all go through it, so data that was accepted before can now raise a
`ValidationError`. The run-time runner had fallen behind the generated Zod schema; it now
passes the same `validation-conformance` corpus. What is newly enforced:
- `validator.numeric @min`/`@max` on `field.int`, `long`, `currency`, `double` and `float`
(rule `numeric`). These bounds were ignored at run time.
- `validator.array @min`/`@max` on an array field's element count (rule `array`). Also
ignored before.
- `field.uri` must be an absolute URI and `field.inet` an IPv4 or IPv6 literal (rule
`format`); a non-string value for either is a `type` failure. `@lenient: true` opts out
of the format check.
- An assigned primary key (no `@generation: increment` or `uuid`, no `@default`) is
`required` on insert. `create` already refused this; `validate()` now reports it too.
- `@maxLength` and `validator.length @max` on one field are strictest-wins. The runner
used `@maxLength` alone, so a tighter validator bound was not applied.

One resolution is corrected: a package-qualified `@objectRef` (`billing::Address`) on a
value-object field now resolves to the object in that package. It resolved to the first
object of that bare name, so with two same-named value objects the wrong one's rules ran.

One rule is relaxed: an authored `validator.length @min: 0` on a `@required` string now
admits the empty string, as the generated schema already did.

### Fixed

- **Java OMDB reads a projection whose view is named by `@view`.** The read mapping took the
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`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 |
| [`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) |
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 16 cases | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 4 | ✓ | ✓ | ✓ | ✓ | ✓ |
Expand Down
46 changes: 46 additions & 0 deletions docs/ports/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,52 @@ name_field = [f for f in author.children() if f.name == "name"][0]
print(name_field.get_meta_attr("maxLength")) # -> 200
```

### Run-time validation

`run_validators` checks a data mapping against an entity's metadata, with no generated
code and no database. It is the Python port of TypeScript's `runValidators`: the same
rules, the same failure structure and the same message text.

```python
from metaobjects.runtime import run_validators

account = next(c for c in result.root.children() if c.name == "Account")
outcome = run_validators(account, {"name": "", "score": 101})
outcome.ok # -> False
[e.to_dict() for e in outcome.errors]
# [{"field": "name", "rule": "length", "message": "'name' must be at least 1 chars (got 0)",
# "expected": {"min": 1}, "received": 0},
# {"field": "score", "rule": "numeric", "message": "'score' must be at most 100 (got 101)",
# "expected": {"max": 100}, "received": 101}]
```

It never raises. Every failure on every field is collected; a `required` or `type`
failure stops further checks on that one value only. The rules, by `rule` name:

| `rule` | Fires when |
|---|---|
| `required` | a `@required` field, a field with `validator.required`, or an assigned primary key (no `@generation: increment` or `uuid`) is absent or `None` |
| `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 |
| `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 |
| `regex` | a string does not fully match `validator.regex @pattern` |
| `numeric` | a number is outside `validator.numeric @min`/`@max` (inclusive) |
| `array` | an array's element count is outside `validator.array @min`/`@max` |
| `format` | a `field.uri` is not an absolute URI, or a `field.inet` is not an IPv4/IPv6 literal. `@lenient: true` opts out |

Keyword options: `partial=True` is update mode, where an absent key is untouched and
only present keys are checked. `store_filled=[...]` names fields the store fills on
insert, which are then exempt from `required` when absent. A field with a `@default` is
also exempt when absent. A value object is validated in full, and its failures are
labelled `field.member` or `field[i].member`.

`ObjectManager.validate(entity_name, data)` returns the same result for a loaded entity.
The `ObjectManager` write methods do not call it, so validate first when the data is
untrusted.

Three details follow JavaScript so that both runners report identical failures: string
length counts UTF-16 code units, a `bool` is not accepted as a number, and a number in a
message prints as JavaScript prints it (`2.0` as `2`, `1e-07` as `1e-7`).

## FR-004 — render

`render` takes a `RenderRequest` (only `payload` + `provider` are required; `ref`
Expand Down
105 changes: 105 additions & 0 deletions docs/superpowers/plans/2026-10-04-python-validator-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Python Run-time Validator Runner Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give the Python port a run-time validator runner equal to TypeScript's `runValidators`, and make both runners pass `fixtures/validation-conformance/`.

**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.

**Tech Stack:** Python 3 standard library only (`re`), pytest; TypeScript, `bun test`.

**Spec:** no separate spec. The contract is `server/typescript/packages/runtime-ts/src/validator-runner.ts` plus `fixtures/validation-conformance/README.md`.

## Global Constraints

- No new vocabulary, no new validator subtype, no metamodel change. Java is not touched.
- The Python runner adds no runtime dependency (`PyYAML` stays the only one).
- ADR-0039: read effective values. Python `attr()` is OWN-only — use `get_meta_attr()` / `children()`. The one own read is `@dbColumnType`.
- Rules, rule names, field labels and message text are byte-identical in both runners.
- The behaviour change for TypeScript users goes in `CHANGELOG.md` under `[Unreleased]` (the 1.1 line).

## The rules both runners implement

Per field, in declaration order (effective children):

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).
2. Open-bag jsonb string, value-object recursion: unchanged.
3. **array** (new) — on an array field, `validator.array @min/@max` bounds the element count:
`{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.
4. **type** — unchanged, plus `field.uri` / `field.inet` must be a string (`expected string`).
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.
6. **regex** — unchanged (full match).
7. **numeric** (new) — on `field.int|long|currency|double|float`, `validator.numeric @min/@max`, inclusive:
`{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.
8. **format** (new) — unless `@lenient: true`:
- `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}`.
- `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}`.

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.

## Review Focus

- Python `bool` is an `int`: `True` on a numeric field must be a type failure, as in TypeScript.
- A `1.0` bound must print as `1`, as JavaScript prints it.
- A non-BMP character counts as 2 toward length in both ports.
- An invalid `@pattern` yields a `regex` failure, never an exception.
- `partial=True` with an absent assigned primary key is not a failure; a present `None` is.

---

### Task 1: Pin the failure list — `fixtures/validation-conformance/runtime-errors.json`

- [ ] Add `runtime-errors.json`: `{ "errors": { "<case name>": [ {field, rule, message, expected?, received?} ] } }`, one entry per `expectValid: false` case in `cases.json`.
- [ ] Document the file and the two run-time runners in the corpus `README.md`.

### Task 2: TypeScript `runValidators` — corpus + new rules

**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`).

- [ ] 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.
- [ ] Add unit tests for rules 1, 3, 5, 7, 8 and the Review Focus lines that apply to TypeScript. Run; expect failures.
- [ ] Implement the rules. Run both test files; expect green. Run `bun test` in `runtime-ts` and `bun run --filter '*' typecheck`.
- [ ] Commit.

### Task 3: Python `run_validators`

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

**Interfaces:**

```python
@dataclass(frozen=True)
class ValidationFailure:
field: str
rule: str
message: str
expected: object = None
received: object = None
def to_dict(self) -> dict[str, object]: ... # omits expected/received when None

@dataclass(frozen=True)
class ValidationResult:
ok: bool
errors: tuple[ValidationFailure, ...] = ()

def run_validators(entity: MetaData, data: Mapping[str, object], *,
partial: bool = False, store_filled: Sequence[str] = ()) -> ValidationResult: ...

class ObjectManager:
def validate(self, entity_name: str, data: Mapping[str, object]) -> ValidationResult: ...
```

- [ ] 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.
- [ ] Implement. Run `uv run pytest tests/runtime -q`, `uv run mypy`, `uv run ruff check`; expect green.
- [ ] Commit.

### Task 4: Docs and changelog

- [ ] `docs/ports/python.md`: document `run_validators` and `ObjectManager.validate`.
- [ ] `docs/CONFORMANCE.md`: correct the corpus case count and note the two run-time runners.
- [ ] `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).
- [ ] Commit.

### Task 5: Verify

- [ ] `scripts/ci-local.sh` (full) green; one independent review of the branch; fix findings.
22 changes: 22 additions & 0 deletions fixtures/validation-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ meta.json # `Account` (package acme::auth) exercising each constraint once,
# plus `Ledger` — an ASSIGNED primary key (see below)
cases.json # [{ name, entity?, payload, expectValid }] — single-source boolean verdicts
# `entity` is optional and defaults to `Account`
runtime-errors.json # the exact failure list per rejected case — run-time runners only (below)
README.md
```

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

## Run-time runners (TypeScript and Python)

Two ports also ship a metadata-driven **run-time** runner that needs no generated code:
TypeScript `runValidators` (`@metaobjectsdev/runtime-ts`) and Python `run_validators`
(`metaobjects.runtime`). Both run every case here and assert the same boolean verdict.

`runtime-errors.json` goes further for these two: for each rejected case it pins the
exact failure list — `{ field, rule, message, expected?, received? }` — and both runners
assert it by deep equality. That is what holds them to identical structure and message
text, which a boolean verdict cannot. It applies to the run-time runners only; the
generated artifacts report in their own native error shapes and stay on the boolean
verdict.

The run-time rule for `field.uri` is an explicit pattern (a scheme, a non-empty
remainder, and a non-empty authority after `//`), not a platform URL parser, so the two
runners agree outside the pinned probe set too. It can therefore differ from the
generated Zod `.url()` in the unpinned gray zone above.

Runners: `server/typescript/packages/integration-tests/test/validation-conformance-runtime.test.ts`
and `server/python/tests/runtime/test_validation_conformance_runtime.py`.

## CI gate

All five port runners assert byte-identical boolean verdicts across all five
Expand Down
Loading
Loading