diff --git a/CHANGELOG.md b/CHANGELOG.md index 76af5ef35..a810f03de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`), @@ -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 diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 9ed4560fc..1b9dc0f2b 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -35,7 +35,7 @@ regenerate with `ls -d fixtures//*/ | 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 | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/docs/ports/python.md b/docs/ports/python.md index bf5300c84..801cbede4 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -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` diff --git a/docs/superpowers/plans/2026-10-04-python-validator-runner.md b/docs/superpowers/plans/2026-10-04-python-validator-runner.md new file mode 100644 index 000000000..3de846448 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-python-validator-runner.md @@ -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": { "": [ {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. diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index 26341ac3a..0bb31d696 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -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 ``` @@ -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 diff --git a/fixtures/validation-conformance/runtime-errors.json b/fixtures/validation-conformance/runtime-errors.json new file mode 100644 index 000000000..1f70ea54c --- /dev/null +++ b/fixtures/validation-conformance/runtime-errors.json @@ -0,0 +1,212 @@ +{ + "errors": { + "name-missing": [ + { + "field": "name", + "rule": "required", + "message": "'name' is required" + } + ], + "name-too-long": [ + { + "field": "name", + "rule": "length", + "message": "'name' must be at most 10 chars (got 12)", + "expected": { + "max": 10 + }, + "received": 12 + } + ], + "code-too-short": [ + { + "field": "code", + "rule": "length", + "message": "'code' must be at least 3 chars (got 2)", + "expected": { + "min": 3 + }, + "received": 2 + } + ], + "code-pattern-mismatch": [ + { + "field": "code", + "rule": "regex", + "message": "'code' does not match required pattern", + "expected": "[A-Z]+", + "received": "abc" + } + ], + "score-below-min": [ + { + "field": "score", + "rule": "numeric", + "message": "'score' must be at least 0 (got -1)", + "expected": { + "min": 0 + }, + "received": -1 + } + ], + "score-above-max": [ + { + "field": "score", + "rule": "numeric", + "message": "'score' must be at most 100 (got 101)", + "expected": { + "max": 100 + }, + "received": 101 + } + ], + "tags-empty": [ + { + "field": "tags", + "rule": "array", + "message": "'tags' must have at least 1 items (got 0)", + "expected": { + "min": 1 + }, + "received": 0 + } + ], + "tags-too-many": [ + { + "field": "tags", + "rule": "array", + "message": "'tags' must have at most 3 items (got 4)", + "expected": { + "max": 3 + }, + "received": 4 + } + ], + "name-empty": [ + { + "field": "name", + "rule": "length", + "message": "'name' must be at least 1 chars (got 0)", + "expected": { + "min": 1 + }, + "received": 0 + } + ], + "pattern-unanchored": [ + { + "field": "code", + "rule": "regex", + "message": "'code' does not match required pattern", + "expected": "[A-Z]+", + "received": "xxABCyy" + } + ], + "both-length-bounds": [ + { + "field": "label", + "rule": "length", + "message": "'label' must be at most 4 chars (got 5)", + "expected": { + "max": 4 + }, + "received": 5 + } + ], + "note-missing": [ + { + "field": "note", + "rule": "required", + "message": "'note' is required" + } + ], + "uri-reject-schemeless": [ + { + "field": "website", + "rule": "format", + "message": "'website' must be an absolute URI", + "expected": "uri", + "received": "example.com" + } + ], + "uri-reject-relative": [ + { + "field": "website", + "rule": "format", + "message": "'website' must be an absolute URI", + "expected": "uri", + "received": "/path/only" + } + ], + "uri-reject-garbage": [ + { + "field": "website", + "rule": "format", + "message": "'website' must be an absolute URI", + "expected": "uri", + "received": "not a url" + } + ], + "uri-reject-empty-authority": [ + { + "field": "website", + "rule": "format", + "message": "'website' must be an absolute URI", + "expected": "uri", + "received": "http://" + } + ], + "inet-reject-hostname": [ + { + "field": "sourceIp", + "rule": "format", + "message": "'sourceIp' must be an IPv4 or IPv6 address", + "expected": "inet", + "received": "example.com" + } + ], + "inet-reject-octet-range": [ + { + "field": "sourceIp", + "rule": "format", + "message": "'sourceIp' must be an IPv4 or IPv6 address", + "expected": "inet", + "received": "256.1.1.1" + } + ], + "inet-reject-cidr": [ + { + "field": "sourceIp", + "rule": "format", + "message": "'sourceIp' must be an IPv4 or IPv6 address", + "expected": "inet", + "received": "192.168.0.1/24" + } + ], + "inet-reject-padded": [ + { + "field": "sourceIp", + "rule": "format", + "message": "'sourceIp' must be an IPv4 or IPv6 address", + "expected": "inet", + "received": " 192.168.0.1 " + } + ], + "inet-reject-leading-zero": [ + { + "field": "sourceIp", + "rule": "format", + "message": "'sourceIp' must be an IPv4 or IPv6 address", + "expected": "inet", + "received": "192.168.01.1" + } + ], + "assigned-pk-missing": [ + { + "field": "code", + "rule": "required", + "message": "'code' is required" + } + ] + } +} diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index d6493e62a..e28ed9dbc 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -518,7 +518,7 @@ gate_conf_ts() { ( cd server/typescript/packages/metadata && bun test test/conformance.test.ts test/yaml-conformance.test.ts test/object-model-conformance.test.ts \ && bun test test/registry-conformance.test.ts test/registry-coverage.test.ts ) || return 1 ( cd server/typescript/packages/render && bun test test/render-conformance.test.ts test/verify-conformance.test.ts test/extract/extract-conformance.test.ts test/output-prompt-conformance.test.ts ) || return 1 - ( cd server/typescript/packages/integration-tests && bun test test/validation-conformance.test.ts ) || return 1 + ( cd server/typescript/packages/integration-tests && bun test test/validation-conformance.test.ts test/validation-conformance-runtime.test.ts ) || return 1 ( cd server/typescript/packages/migrate-ts && bun test ) || return 1 ( cd server/typescript/packages/codegen-ts && bun test ) || return 1 # --timeout 30000: the cli suite invokes full `meta`/run() dispatch, which lazily diff --git a/server/python/src/metaobjects/runtime/__init__.py b/server/python/src/metaobjects/runtime/__init__.py index c0ab81386..f4f37097e 100644 --- a/server/python/src/metaobjects/runtime/__init__.py +++ b/server/python/src/metaobjects/runtime/__init__.py @@ -7,6 +7,7 @@ combinator + asc/desc sort + limit/offset. """ from .object_manager import Filter, ObjectManager, PostgresDriver +from .validator_runner import ValidationFailure, ValidationResult, run_validators from .llm_recorder import ( STATUS_ERROR, STATUS_OK, @@ -25,6 +26,9 @@ "Filter", "ObjectManager", "PostgresDriver", + "ValidationFailure", + "ValidationResult", + "run_validators", "STATUS_OK", "STATUS_ERROR", "LlmCallInput", diff --git a/server/python/src/metaobjects/runtime/object_manager.py b/server/python/src/metaobjects/runtime/object_manager.py index c111e024d..42a74a356 100644 --- a/server/python/src/metaobjects/runtime/object_manager.py +++ b/server/python/src/metaobjects/runtime/object_manager.py @@ -29,7 +29,7 @@ import decimal as _decimal import json as _json import uuid as _uuid -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from typing import Any, Protocol from ..meta.meta_root import MetaRoot @@ -50,6 +50,7 @@ resolve_n2m_descriptor, ) from .tph import TphSubtype, tph_subtype_of +from .validator_runner import ValidationResult, run_validators # Filter shape: @@ -214,6 +215,13 @@ def __init__( # --- Public API ---------------------------------------------------------- + def validate(self, entity_name: str, data: Mapping[str, Any]) -> ValidationResult: + """Validate ``data`` against ``entity_name``'s metadata — no database access, and + nothing is raised for invalid data: the failures come back on the result. Mirrors + the TS ``om.validate()``. The write methods do not call it; validate first when + the data is untrusted.""" + return run_validators(self._declared_entity(entity_name), data) + def find_by_id(self, entity_name: str, id_value: Any) -> dict[str, Any] | None: self._refuse_report("find_by_id", entity_name) entity = self._require_entity(entity_name) diff --git a/server/python/src/metaobjects/runtime/validator_runner.py b/server/python/src/metaobjects/runtime/validator_runner.py new file mode 100644 index 000000000..ca047f0ab --- /dev/null +++ b/server/python/src/metaobjects/runtime/validator_runner.py @@ -0,0 +1,465 @@ +"""Run-time validator runner — validate a data mapping against an entity's metadata. + +The Python port of the TS ``runValidators`` +(``server/typescript/packages/runtime-ts/src/validator-runner.ts``), which is the +contract: the same rules, the same failure structure, the same message text and the +same ordering. ``fixtures/validation-conformance/`` gates both runners, and its +``runtime-errors.json`` pins the exact failure list each must produce. + +A pure function: it NEVER raises. Every failure across every field is collected; a +required or type failure stops further checks on that one value only. + +Three places where Python would naturally differ from JavaScript are held to the JS +behaviour, because the failure list is byte-compared across the two runners: + +- a ``bool`` is not a number (``isinstance(True, int)`` is true in Python); +- string length counts UTF-16 code units, as JS ``String.length`` does; +- a number in a message prints as JS prints it (``2.0`` is ``2``, ``1e-07`` is ``1e-7``). +""" +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal + +from ..meta.core.field import field_constants as fc +from ..meta.core.identity import identity_constants as ic +from ..meta.core.object.meta_object import MetaObject +from ..meta.core.object.object_constants import OBJECT_SUBTYPE_VALUE +from ..meta.core.validator import validator_constants as vc +from ..meta.meta_data import MetaData +from ..meta.persistence.db import db_constants as dbc +from ..naming_refs import resolve_object_ref +from ..shared.base_types import TYPE_FIELD, TYPE_VALIDATOR + + +@dataclass(frozen=True) +class ValidationFailure: + """One failed rule. ``field`` is the field name, or ``name[i]`` for an array element + and ``name.member`` / ``name[i].member`` inside a value object. ``expected`` and + ``received`` are ``None`` when the rule reports none.""" + + field: str + rule: str + message: str + expected: object = None + received: object = None + + def to_dict(self) -> dict[str, object]: + """The wire shape — ``expected`` / ``received`` are omitted when absent, exactly + as the TS runner omits them.""" + out: dict[str, object] = {"field": self.field, "rule": self.rule, "message": self.message} + if self.expected is not None: + out["expected"] = self.expected + if self.received is not None: + out["received"] = self.received + return out + + +@dataclass(frozen=True) +class ValidationResult: + """``ok`` is true exactly when ``errors`` is empty.""" + + ok: bool + errors: tuple[ValidationFailure, ...] = () + + +# Fields that must arrive as a number. +_NUMERIC_FIELD_SUBTYPES = frozenset({fc.FIELD_SUBTYPE_INT, fc.FIELD_SUBTYPE_DOUBLE, fc.FIELD_SUBTYPE_FLOAT}) +# 64-bit integer fields (BIGINT on the wire). The write contract also accepts a base-10 +# integer string, so a full int64 survives a JSON transport that cannot carry one. +_INT64_FIELD_SUBTYPES = frozenset({fc.FIELD_SUBTYPE_LONG, fc.FIELD_SUBTYPE_CURRENCY}) +_STRING_FIELD_SUBTYPES = frozenset({ + fc.FIELD_SUBTYPE_STRING, fc.FIELD_SUBTYPE_UUID, fc.FIELD_SUBTYPE_URI, fc.FIELD_SUBTYPE_INET, +}) + +# ASCII digits only — ``\d`` would also admit other Unicode digits, which JS ``\d`` does not. +_INT64_STRING_RE = re.compile(r"-?[0-9]+") + +# The IPv4 and IPv6 literal patterns the TS runner uses (``runtime-ts/src/net-format.ts``), +# applied with ``fullmatch`` — a Python ``$`` would also match before a trailing newline. +_IPV4_RE = re.compile( + r"(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}" + r"(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])" +) +_IPV6_RE = re.compile( + r"(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:" + r"|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}" + r"|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}" + r"|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})" + r"|:((:[0-9a-fA-F]{1,4}){1,7}|:)" + r"|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])" + r"|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))" +) + +# Leading/trailing C0 controls and spaces, which a URL parser strips before parsing. +_URI_PAD = "".join(chr(c) for c in range(0x21)) +_URI_SCHEME_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:(.*)", re.DOTALL) +_URI_AUTHORITY_END_RE = re.compile(r"[/?#]") + + +def run_validators( + entity: MetaData, + data: Mapping[str, object], + *, + partial: bool = False, + store_filled: Sequence[str] = (), +) -> ValidationResult: + """Validate ``data`` against ``entity``'s fields and their validators. + + ``partial`` is update mode: a required check fires only for a key present in ``data``. + ``store_filled`` names fields the store fills on insert (a driver-generated primary + key) — exempt from required when ABSENT; a present ``None`` is still a failure. + """ + errors: list[ValidationFailure] = [] + assigned_pk = _assigned_pk_field_names(entity) + + # ADR-0039: effective children, so a subtype validates inherited base fields too. + for field in entity.children(): + if field.type != TYPE_FIELD: + continue + name = field.name + present = name in data + value = data.get(name) + + required = _is_required(field) + # An ASSIGNED primary key must be supplied whatever @required says: nothing else + # can produce the value. Presence only — the non-empty-string floor stays tied to + # a DECLARED required. + if (required or name in assigned_pk) and value is None: + if partial and not present: + continue + # A @default (ADR-0039: resolving) exempts an ABSENT field only — the store + # fills it. An explicit null is a deliberate clear the default cannot cover. + if not present and field.get_meta_attr(fc.FIELD_ATTR_DEFAULT) is not None: + continue + if not present and name in store_filled: + continue + errors.append(ValidationFailure(name, "required", f"'{name}' is required")) + continue + + if value is None: + continue + + # Open-bag jsonb column: holds ANY JSON value, so no string checks apply. + # ADR-0039 sanctioned own: @dbColumnType is the one deliberately own-only attr + # (physical, never inherited). + if ( + field.sub_type == fc.FIELD_SUBTYPE_STRING + and field.attr(dbc.FIELD_ATTR_DB_COLUMN_TYPE) == dbc.DB_COLUMN_TYPE_JSONB + ): + continue + + is_array = field.resolved_is_array() + + if field.sub_type == fc.FIELD_SUBTYPE_OBJECT: + vo = _resolve_vo_ref(field) + if vo is not None: + errors.extend(_value_object_errors(field, vo, value, is_array)) + continue + + if is_array: + if not _is_js_array(value): + errors.append(ValidationFailure( + name, "type", f"'{name}' must be an array", "array", _js_typeof(value), + )) + continue + elements = list(value) # type: ignore[call-overload] + errors.extend(_array_size_errors(field, len(elements))) + for i, el in enumerate(elements): + if el is not None: + errors.extend(_scalar_errors(field, el, False, f"{name}[{i}]")) + continue + + errors.extend(_scalar_errors(field, value, required, name)) + + return ValidationResult(ok=not errors, errors=tuple(errors)) + + +def _value_object_errors( + field: MetaData, vo: MetaObject, value: object, is_array: bool, +) -> list[ValidationFailure]: + """A present value object is validated in FULL (never partial), single or array.""" + name = field.name + if is_array and not _is_js_array(value): + return [ValidationFailure( + name, "type", f"'{name}' must be an array of {vo.name}", "array", _js_typeof(value), + )] + elements = list(value) if is_array else [value] # type: ignore[call-overload] + errors: list[ValidationFailure] = [] + if is_array: + errors.extend(_array_size_errors(field, len(elements))) + for i, el in enumerate(elements): + if not isinstance(el, Mapping): + errors.append(ValidationFailure( + name, "type", f"'{name}' must be a {vo.name} object", vo.name, + "null" if el is None else _js_typeof(el), + )) + continue + prefix = f"{name}[{i}]" if is_array else name + for e in run_validators(vo, el).errors: + errors.append(ValidationFailure(f"{prefix}.{e.field}", e.rule, e.message, e.expected, e.received)) + return errors + + +def _resolve_vo_ref(field: MetaData) -> MetaObject | None: + """The ``object.value`` a ``field.object``'s ``@objectRef`` names, found by walking to + the tree root. ``None`` when unresolvable or when the target is not a value object — + then there is no recursion, on every port.""" + # ADR-0039: resolving — @objectRef may be inherited via extends. + ref = field.get_meta_attr(fc.FIELD_ATTR_OBJECT_REF) + if not isinstance(ref, str) or not ref: + return None + root: MetaData = field + while root.parent is not None: + root = root.parent + # ADR-0042 — the SINGLE object-ref resolver every ref site shares: an FQN matches + # its resolution key exactly; a bare ref resolves in the DECLARING owner's package + # (an inherited field resolves in the package that declared it), then a root-level + # object — never a same-named object in some other package. + owner = field.parent if field.parent is not None else root + referrer_pkg = owner.package or owner.file_default_package or "" + target = resolve_object_ref(root, ref, referrer_pkg) + return target if isinstance(target, MetaObject) and target.sub_type == OBJECT_SUBTYPE_VALUE else None + + +def _assigned_pk_field_names(entity: MetaData) -> frozenset[str]: + """Primary-identity field names the CALLER must supply: the identity carries no + store-side ``@generation`` (increment / uuid).""" + primary = entity.primary_identity() if isinstance(entity, MetaObject) else None + if primary is None: + return frozenset() + # ADR-0039: resolving — an identity's attrs may be inherited via extends. + if primary.get_meta_attr(ic.IDENTITY_ATTR_GENERATION) in (ic.GENERATION_INCREMENT, ic.GENERATION_UUID): + return frozenset() + fields = primary.get_meta_attr(ic.IDENTITY_ATTR_FIELDS) + if isinstance(fields, str): + return frozenset({fields}) + if isinstance(fields, (list, tuple)): + return frozenset(str(f) for f in fields) + return frozenset() + + +def _validators(field: MetaData, sub_type: str) -> list[MetaData]: + # ADR-0039: effective children — a validator may be inherited via extends. + return [c for c in field.children() if c.type == TYPE_VALIDATOR and c.sub_type == sub_type] + + +def _is_required(field: MetaData) -> bool: + # ADR-0039: resolving — @required may be inherited via extends. + if field.get_meta_attr(fc.FIELD_ATTR_REQUIRED) is True: + return True + return bool(_validators(field, vc.VALIDATOR_SUBTYPE_REQUIRED)) + + +def _number(value: object) -> int | float | None: + """``value`` when it is a JS ``number`` — an int or float, never a bool.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value + + +def _validator_bounds(field: MetaData, sub_type: str) -> tuple[int | float | None, int | float | None]: + """The ``(@min, @max)`` of the field's validators of one subtype (last authored wins).""" + lo: int | float | None = None + hi: int | float | None = None + for child in _validators(field, sub_type): + # ADR-0039: resolving — a validator's bounds may be inherited via extends. + child_min = _number(child.get_meta_attr(vc.VALIDATOR_ATTR_MIN)) + child_max = _number(child.get_meta_attr(vc.VALIDATOR_ATTR_MAX)) + lo = child_min if child_min is not None else lo + hi = child_max if child_max is not None else hi + return lo, hi + + +def _length_bounds(field: MetaData) -> tuple[int | float | None, int | float | None]: + """String length ``(min, max)``. Max is strictest-wins across ``@maxLength`` and every + ``validator.length @max``. Min is the authored ``validator.length @min``, ``None`` when + none was authored.""" + lo: int | float | None = None + # ADR-0039: resolving — @maxLength may be inherited via extends. + hi = _number(field.get_meta_attr(fc.FIELD_ATTR_MAX_LENGTH)) + for child in _validators(field, vc.VALIDATOR_SUBTYPE_LENGTH): + child_min = _number(child.get_meta_attr(vc.VALIDATOR_ATTR_MIN)) + child_max = _number(child.get_meta_attr(vc.VALIDATOR_ATTR_MAX)) + lo = child_min if child_min is not None else lo + if child_max is not None: + hi = child_max if hi is None else min(hi, child_max) + return lo, hi + + +def _array_size_errors(field: MetaData, size: int) -> list[ValidationFailure]: + """``validator.array @min/@max`` — element-count bounds on an array field.""" + name = field.name + lo, hi = _validator_bounds(field, vc.VALIDATOR_SUBTYPE_ARRAY) + errors: list[ValidationFailure] = [] + if lo is not None and size < lo: + errors.append(ValidationFailure( + name, "array", f"'{name}' must have at least {_js_number(lo)} items (got {size})", {"min": lo}, size, + )) + if hi is not None and size > hi: + errors.append(ValidationFailure( + name, "array", f"'{name}' must have at most {_js_number(hi)} items (got {size})", {"max": hi}, size, + )) + return errors + + +def _check_type(sub_type: str, value: object) -> str | None: + if sub_type in _STRING_FIELD_SUBTYPES: + if not isinstance(value, str): + return "expected string" + elif sub_type in _NUMERIC_FIELD_SUBTYPES: + if _number(value) is None: + return "expected number" + elif sub_type in _INT64_FIELD_SUBTYPES: + if _number(value) is not None: + return None + if isinstance(value, str) and _INT64_STRING_RE.fullmatch(value): + return None + return "expected a 64-bit integer (number, bigint, or numeric string)" + elif sub_type == fc.FIELD_SUBTYPE_BOOLEAN: + if not isinstance(value, bool): + return "expected boolean" + return None + + +def _scalar_errors(field: MetaData, value: object, required: bool, label: str) -> list[ValidationFailure]: + """The type, length, regex, numeric and format failures for one scalar value — the + whole field value, or one element of a scalar array (then ``required`` is false and + ``label`` is ``name[i]``).""" + type_error = _check_type(field.sub_type, value) + if type_error is not None: + return [ValidationFailure(label, "type", type_error, field.sub_type, _js_typeof(value))] + + errors: list[ValidationFailure] = [] + if isinstance(value, str): + length = _utf16_length(value) + min_len, max_len = _length_bounds(field) + if max_len is not None and length > max_len: + errors.append(ValidationFailure( + label, "length", f"'{label}' must be at most {_js_number(max_len)} chars (got {length})", + {"max": max_len}, length, + )) + # A @required string is non-empty by default (a floor of 1), but an authored + # validator.length @min is ALWAYS authoritative over that floor: @min 0 opts out. + effective_min = min_len if min_len is not None else (1 if required else 0) + if effective_min > 0 and length < effective_min: + errors.append(ValidationFailure( + label, "length", f"'{label}' must be at least {_js_number(effective_min)} chars (got {length})", + {"min": effective_min}, length, + )) + + for child in _validators(field, vc.VALIDATOR_SUBTYPE_REGEX): + # ADR-0039: resolving — @pattern may be inherited via extends. + pattern = child.get_meta_attr(vc.VALIDATOR_ATTR_PATTERN) + if not isinstance(pattern, str): + continue + try: + # @pattern is FULL-MATCH: the whole value must match. + matched = re.fullmatch(f"(?:{pattern})", value) is not None + except re.error: + errors.append(ValidationFailure( + label, "regex", f"'{label}' has an invalid validator pattern: {pattern}", pattern, + )) + continue + if not matched: + errors.append(ValidationFailure( + label, "regex", f"'{label}' does not match required pattern", pattern, value, + )) + + # validator.numeric @min/@max — inclusive value bounds on a numeric field. + if field.sub_type in _NUMERIC_FIELD_SUBTYPES or field.sub_type in _INT64_FIELD_SUBTYPES: + # The type check passed, so a string here is a base-10 int64 literal. + num = int(value) if isinstance(value, str) else _number(value) + lo, hi = _validator_bounds(field, vc.VALIDATOR_SUBTYPE_NUMERIC) + if num is not None and lo is not None and num < lo: + errors.append(ValidationFailure( + label, "numeric", f"'{label}' must be at least {_js_number(lo)} (got {_js_number(value)})", + {"min": lo}, value, + )) + if num is not None and hi is not None and num > hi: + errors.append(ValidationFailure( + label, "numeric", f"'{label}' must be at most {_js_number(hi)} (got {_js_number(value)})", + {"max": hi}, value, + )) + + # field.uri / field.inet — the strict format contract, unless @lenient opts out. + # ADR-0039: resolving — @lenient may be inherited via extends. + if isinstance(value, str) and field.get_meta_attr(fc.FIELD_ATTR_LENIENT) is not True: + if field.sub_type == fc.FIELD_SUBTYPE_URI and not _is_absolute_uri(value): + errors.append(ValidationFailure(label, "format", f"'{label}' must be an absolute URI", "uri", value)) + if field.sub_type == fc.FIELD_SUBTYPE_INET and not _is_inet_literal(value): + errors.append(ValidationFailure( + label, "format", f"'{label}' must be an IPv4 or IPv6 address", "inet", value, + )) + return errors + + +def _is_inet_literal(value: str) -> bool: + return _IPV4_RE.fullmatch(value) is not None or _IPV6_RE.fullmatch(value) is not None + + +def _is_absolute_uri(value: str) -> bool: + """A scheme, a non-empty remainder and — when the remainder opens an authority with + ``//`` — a non-empty authority. Padding is stripped first.""" + match = _URI_SCHEME_RE.fullmatch(value.strip(_URI_PAD)) + if match is None or not match.group(1): + return False + rest = match.group(1) + if not rest.startswith("//"): + return True + return bool(_URI_AUTHORITY_END_RE.split(rest[2:], maxsplit=1)[0]) + + +def _is_js_array(value: object) -> bool: + return isinstance(value, (list, tuple)) + + +def _js_typeof(value: object) -> str: + """The JS ``typeof`` name a failure's ``received`` carries, so it matches the TS runner.""" + if isinstance(value, bool): + return "boolean" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, str): + return "string" + return "object" + + +def _utf16_length(value: str) -> int: + """JS ``String.length`` — UTF-16 code units, so a non-BMP character counts as 2.""" + return len(value.encode("utf-16-le", "surrogatepass")) // 2 + + +def _js_number(value: object) -> str: + """``value`` as a JS template literal prints it (ECMAScript ``Number::toString``). + + Python and JS agree on the shortest round-trip DIGITS of a float but lay them out + differently: JS prints an integral float with no ``.0``, switches to exponent notation + only below 1e-6 and from 1e21, writes the exponent unpadded (``1e-7``, ``1e+21``), and + names the non-finite values ``Infinity`` / ``NaN``. An ``int`` and a string print as + they are.""" + if not isinstance(value, float): + return str(value) + if value != value: + return "NaN" + if value in (float("inf"), float("-inf")): + return "Infinity" if value > 0 else "-Infinity" + if value == 0: + return "0" + sign, digit_tuple, exponent = Decimal(repr(abs(value))).as_tuple() + digits = "".join(map(str, digit_tuple)).rstrip("0") + # The decimal point sits after ``point`` digits: value = 0. x 10^point. + point = len(digit_tuple) + int(exponent) + count = len(digits) + if count <= point <= 21: + text = digits + "0" * (point - count) + elif 0 < point <= 21: + text = f"{digits[:point]}.{digits[point:]}" + elif -6 < point <= 0: + text = "0." + "0" * -point + digits + else: + mantissa = digits if count == 1 else f"{digits[0]}.{digits[1:]}" + text = f"{mantissa}e{'+' if point > 0 else '-'}{abs(point - 1)}" + return text if value > 0 else f"-{text}" diff --git a/server/python/tests/runtime/test_validation_conformance_runtime.py b/server/python/tests/runtime/test_validation_conformance_runtime.py new file mode 100644 index 000000000..a7198345b --- /dev/null +++ b/server/python/tests/runtime/test_validation_conformance_runtime.py @@ -0,0 +1,45 @@ +"""validation-conformance RUN-TIME runner (Python port). + +The sibling ``tests/codegen/test_validation_conformance.py`` gates the GENERATED Pydantic +model. This one runs the same corpus through ``run_validators`` — the metadata-driven +run-time runner — and asserts the exact failure list pinned in ``runtime-errors.json``. +The TS ``runValidators`` runner asserts the same file, which is what keeps the two +run-time runners identical in structure and message text and not merely in verdict. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import metaobjects.core_types # noqa: F401 — side effect: registers core types +from metaobjects import MetaDataLoader +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.runtime import run_validators + +# tests/runtime/ -> server/python -> server -> +_CORPUS = Path(__file__).resolve().parents[4] / "fixtures" / "validation-conformance" +_DEFAULT_ENTITY = "Account" +_CASES = json.loads((_CORPUS / "cases.json").read_text())["cases"] +_EXPECTED_ERRORS = json.loads((_CORPUS / "runtime-errors.json").read_text())["errors"] + + +@pytest.fixture(scope="module") +def entities() -> dict[str, MetaObject]: + result = MetaDataLoader.from_string((_CORPUS / "meta.json").read_text()) + assert not result.errors, [str(e) for e in result.errors] + return {c.name: c for c in result.root.children() if isinstance(c, MetaObject)} + + +@pytest.mark.parametrize("case", _CASES, ids=[c["name"] for c in _CASES]) +def test_case(case: dict, entities: dict[str, MetaObject]) -> None: + result = run_validators(entities[case.get("entity", _DEFAULT_ENTITY)], case["payload"]) + assert result.ok is case["expectValid"], result + if not result.ok: + assert [e.to_dict() for e in result.errors] == _EXPECTED_ERRORS.get(case["name"], []) + + +def test_runtime_errors_pins_exactly_the_rejected_cases() -> None: + rejected = sorted(c["name"] for c in _CASES if not c["expectValid"]) + assert sorted(_EXPECTED_ERRORS) == rejected diff --git a/server/python/tests/runtime/test_validator_runner.py b/server/python/tests/runtime/test_validator_runner.py new file mode 100644 index 000000000..0b29e1ce9 --- /dev/null +++ b/server/python/tests/runtime/test_validator_runner.py @@ -0,0 +1,509 @@ +"""``run_validators`` — the Python port of the TS ``runValidators`` run-time runner. + +Mirrors ``server/typescript/packages/runtime-ts/test/validator-runner.test.ts`` case for +case: the same rules, the same failure structure and the same message text. +""" +from __future__ import annotations + +import json + +import metaobjects.core_types # noqa: F401 — side effect: registers core types +from metaobjects import MetaDataLoader +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.runtime import ValidationFailure, ValidationResult, run_validators + + +def _load(*children: dict, name: str = "Post", extra: tuple[dict, ...] = ()) -> MetaObject: + doc = {"metadata.root": {"package": "p", "children": [ + {"object.entity": {"name": name, "children": list(children)}}, *extra, + ]}} + result = MetaDataLoader.from_string(json.dumps(doc)) + assert not result.errors, [str(e) for e in result.errors] + return next(c for c in result.root.children() if isinstance(c, MetaObject) and c.name == name) + + +def _errors(entity: MetaObject, data: dict, **opts: object) -> list[dict]: + return [e.to_dict() for e in run_validators(entity, data, **opts).errors] # type: ignore[arg-type] + + +def _rules(entity: MetaObject, data: dict, **opts: object) -> list[str]: + return [f"{e['field']}:{e['rule']}" for e in _errors(entity, data, **opts)] + + +def _string(name: str, **attrs: object) -> dict: + return {"field.string": {"name": name, **{f"@{k}": v for k, v in attrs.items()}}} + + +def _with(field: dict, *validators: dict) -> dict: + next(iter(field.values()))["children"] = list(validators) + return field + + +# ── result shape ───────────────────────────────────────────────────────────── + + +def test_ok_result_has_no_errors() -> None: + result = run_validators(_load(_string("title")), {"title": "x"}) + assert result == ValidationResult(ok=True, errors=()) + + +def test_failure_to_dict_omits_absent_expected_and_received() -> None: + failure = ValidationFailure(field="title", rule="required", message="'title' is required") + assert failure.to_dict() == {"field": "title", "rule": "required", "message": "'title' is required"} + + +# ── required ───────────────────────────────────────────────────────────────── + + +def test_validator_required_missing_field() -> None: + e = _load(_with(_string("title"), {"validator.required": {}})) + assert _errors(e, {}) == [{"field": "title", "rule": "required", "message": "'title' is required"}] + + +def test_required_attr_shortcut() -> None: + e = _load(_string("title", required=True)) + assert _rules(e, {}) == ["title:required"] + assert _rules(e, {"title": None}) == ["title:required"] + assert _rules(e, {"title": "hello"}) == [] + + +def test_required_string_rejects_empty_but_accepts_whitespace() -> None: + e = _load(_string("title", required=True)) + assert _errors(e, {"title": ""}) == [{ + "field": "title", "rule": "length", "message": "'title' must be at least 1 chars (got 0)", + "expected": {"min": 1}, "received": 0, + }] + assert _rules(e, {"title": " "}) == [] + + +def test_partial_mode_skips_absent_required_but_rejects_present_null() -> None: + e = _load(_string("title", required=True)) + assert _rules(e, {}, partial=True) == [] + assert _rules(e, {"title": None}, partial=True) == ["title:required"] + + +def test_default_exempts_an_absent_required_field_only() -> None: + e = _load(_string("status", required=True, default="new")) + assert _rules(e, {}) == [] + assert _rules(e, {"status": None}) == ["status:required"] + + +def test_store_filled_exempts_an_absent_field_only() -> None: + e = _load(_string("id", required=True)) + assert _rules(e, {}, store_filled=["id"]) == [] + assert _rules(e, {"id": None}, store_filled=["id"]) == ["id:required"] + + +# ── length ─────────────────────────────────────────────────────────────────── + + +def test_length_max_and_min() -> None: + e = _load(_with(_string("title"), {"validator.length": {"@min": 3, "@max": 5}})) + assert _errors(e, {"title": "toolong"}) == [{ + "field": "title", "rule": "length", "message": "'title' must be at most 5 chars (got 7)", + "expected": {"max": 5}, "received": 7, + }] + assert _errors(e, {"title": "ab"}) == [{ + "field": "title", "rule": "length", "message": "'title' must be at least 3 chars (got 2)", + "expected": {"min": 3}, "received": 2, + }] + + +def test_max_length_and_validator_max_is_strictest_wins() -> None: + e = _load(_with(_string("label", maxLength=8), {"validator.length": {"@max": 4}})) + assert _rules(e, {"label": "1234"}) == [] + assert _errors(e, {"label": "12345"})[0]["expected"] == {"max": 4} + + +def test_authored_min_zero_opts_out_of_the_required_floor() -> None: + e = _load(_with(_string("note", required=True), {"validator.length": {"@min": 0}})) + assert _rules(e, {"note": ""}) == [] + assert _rules(e, {}) == ["note:required"] + + +def test_length_counts_utf16_code_units() -> None: + e = _load(_string("icon", maxLength=1)) + assert _errors(e, {"icon": "\U0001F600"})[0]["received"] == 2 + + +# ── regex ──────────────────────────────────────────────────────────────────── + + +def test_regex_is_full_match() -> None: + e = _load(_with(_string("code"), {"validator.regex": {"@pattern": "[A-Z]+"}})) + assert _rules(e, {"code": "ABC"}) == [] + assert _errors(e, {"code": "xxABCyy"}) == [{ + "field": "code", "rule": "regex", "message": "'code' does not match required pattern", + "expected": "[A-Z]+", "received": "xxABCyy", + }] + + +def test_regex_rejects_a_trailing_newline() -> None: + e = _load(_with(_string("code"), {"validator.regex": {"@pattern": "[A-Z]+"}})) + assert _rules(e, {"code": "ABC\n"}) == ["code:regex"] + + +def test_invalid_pattern_is_a_structured_error_never_an_exception() -> None: + e = _load(_with(_string("code"), {"validator.regex": {"@pattern": "("}})) + assert _errors(e, {"code": "x"}) == [{ + "field": "code", "rule": "regex", + "message": "'code' has an invalid validator pattern: (", "expected": "(", + }] + + +# ── type checks ────────────────────────────────────────────────────────────── + + +def test_type_failures_use_the_ts_messages_and_typeof_names() -> None: + e = _load( + {"field.int": {"name": "count"}}, {"field.boolean": {"name": "live"}}, _string("title"), + {"field.double": {"name": "ratio"}}, + ) + assert _errors(e, {"count": "x", "live": 1, "title": 5, "ratio": "1.5"}) == [ + {"field": "count", "rule": "type", "message": "expected number", "expected": "int", "received": "string"}, + {"field": "live", "rule": "type", "message": "expected boolean", "expected": "boolean", "received": "number"}, + {"field": "title", "rule": "type", "message": "expected string", "expected": "string", "received": "number"}, + {"field": "ratio", "rule": "type", "message": "expected number", "expected": "double", "received": "string"}, + ] + + +def test_a_bool_is_not_a_number() -> None: + e = _load({"field.int": {"name": "count"}}, {"field.long": {"name": "total"}}) + assert _errors(e, {"count": True, "total": False}) == [ + {"field": "count", "rule": "type", "message": "expected number", "expected": "int", "received": "boolean"}, + {"field": "total", "rule": "type", + "message": "expected a 64-bit integer (number, bigint, or numeric string)", + "expected": "long", "received": "boolean"}, + ] + + +def test_null_skips_the_type_check() -> None: + assert _rules(_load({"field.int": {"name": "count"}}), {"count": None}) == [] + + +def test_int64_fields_accept_a_number_or_an_integer_string() -> None: + e = _load({"field.long": {"name": "total"}}, {"field.currency": {"name": "price"}}) + assert _rules(e, {"total": "9223372036854775807", "price": "-12"}) == [] + assert _rules(e, {"total": 9223372036854775807, "price": 12}) == [] + assert _rules(e, {"total": "1.5"}) == ["total:type"] + assert _rules(e, {"price": "12"}) == ["price:type"] # non-ASCII digits are not an int64 literal + + +def test_type_failure_stops_further_checks_on_that_value() -> None: + e = _load(_with(_string("code", maxLength=1), {"validator.regex": {"@pattern": "[A-Z]+"}})) + assert _rules(e, {"code": 12}) == ["code:type"] + + +def test_collects_all_failures_across_fields() -> None: + e = _load(_string("title", required=True), _string("slug", maxLength=3), {"field.int": {"name": "n"}}) + assert _rules(e, {"slug": "toolong", "n": "x"}) == ["title:required", "slug:length", "n:type"] + + +def test_open_bag_jsonb_string_holds_any_json_value() -> None: + e = _load(_string("bag", dbColumnType="jsonb", maxLength=2)) + assert _rules(e, {"bag": {"a": [1, 2, 3]}}) == [] + + +# ── arrays ─────────────────────────────────────────────────────────────────── + + +def _tags(**validator: object) -> MetaObject: + field = {"field.string": {"name": "tags", "isArray": True, "@maxLength": 3}} + if validator: + _with(field, {"validator.array": {f"@{k}": v for k, v in validator.items()}}) + return _load(field, {"field.int": {"name": "scores", "isArray": True}}) + + +def test_scalar_array_of_valid_elements_passes() -> None: + assert _rules(_tags(), {"tags": ["a", "bc"], "scores": [1, 2]}) == [] + + +def test_scalar_array_non_array_value_is_a_type_error() -> None: + assert _errors(_tags(), {"tags": "a"}) == [{ + "field": "tags", "rule": "type", "message": "'tags' must be an array", + "expected": "array", "received": "string", + }] + + +def test_scalar_array_element_error_names_its_index() -> None: + assert _rules(_tags(), {"tags": ["ok", "toolong"], "scores": [1, "x"]}) == ["tags[1]:length", "scores[1]:type"] + + +def test_array_size_bounds() -> None: + e = _tags(min=1, max=3) + assert _errors(e, {"tags": []}) == [{ + "field": "tags", "rule": "array", "message": "'tags' must have at least 1 items (got 0)", + "expected": {"min": 1}, "received": 0, + }] + assert _errors(e, {"tags": ["a", "b", "c", "d"]}) == [{ + "field": "tags", "rule": "array", "message": "'tags' must have at most 3 items (got 4)", + "expected": {"max": 3}, "received": 4, + }] + + +def test_element_errors_are_reported_alongside_a_size_failure() -> None: + assert _rules(_tags(min=1, max=3), {"tags": ["a", "b", "c", 4]}) == ["tags:array", "tags[3]:type"] + + +def test_validator_array_on_a_non_array_field_is_ignored() -> None: + e = _load(_with(_string("name"), {"validator.array": {"@min": 2}})) + assert _rules(e, {"name": "x"}) == [] + + +# ── numeric ────────────────────────────────────────────────────────────────── + + +def _score() -> MetaObject: + return _load({"field.int": {"name": "score", "children": [{"validator.numeric": {"@min": 0, "@max": 100}}]}}) + + +def test_numeric_bounds_are_inclusive() -> None: + assert _rules(_score(), {"score": 0}) == [] + assert _rules(_score(), {"score": 100}) == [] + + +def test_numeric_below_min_and_above_max() -> None: + assert _errors(_score(), {"score": -1}) == [{ + "field": "score", "rule": "numeric", "message": "'score' must be at least 0 (got -1)", + "expected": {"min": 0}, "received": -1, + }] + assert _errors(_score(), {"score": 101}) == [{ + "field": "score", "rule": "numeric", "message": "'score' must be at most 100 (got 101)", + "expected": {"max": 100}, "received": 101, + }] + + +def test_numeric_int64_string_is_compared_as_an_integer_and_echoed_as_given() -> None: + e = _load({"field.long": {"name": "total", "children": [{"validator.numeric": {"@min": 10}}]}}) + assert _rules(e, {"total": "9223372036854775807"}) == [] + assert _errors(e, {"total": "5"}) == [{ + "field": "total", "rule": "numeric", "message": "'total' must be at least 10 (got 5)", + "expected": {"min": 10}, "received": "5", + }] + + +def test_numeric_message_prints_an_integral_float_as_javascript_does() -> None: + e = _load({"field.double": {"name": "ratio", "children": [{"validator.numeric": {"@max": 1}}]}}) + assert _errors(e, {"ratio": 2.0})[0]["message"] == "'ratio' must be at most 1 (got 2)" + assert _errors(e, {"ratio": 1.5})[0]["message"] == "'ratio' must be at most 1 (got 1.5)" + + +def test_validator_numeric_on_a_string_field_is_ignored() -> None: + e = _load(_with(_string("code"), {"validator.numeric": {"@min": 5}})) + assert _rules(e, {"code": "1"}) == [] + + +# ── field.uri / field.inet ─────────────────────────────────────────────────── + + +def _net() -> MetaObject: + return _load( + {"field.uri": {"name": "website"}}, {"field.inet": {"name": "sourceIp"}}, + {"field.uri": {"name": "citationUrl", "@lenient": True}}, + {"field.inet": {"name": "reportedIp", "@lenient": True}}, + ) + + +def test_strict_uri_accepts_an_absolute_uri_padded_or_not() -> None: + for website in ["https://a.com", " https://a.com ", "mailto:a@b.com", "urn:isbn:0451450523"]: + assert _rules(_net(), {"website": website}) == [] + + +def test_strict_uri_rejects_schemeless_empty_authority_and_bare_scheme() -> None: + for website in ["example.com", "/path/only", "not a url", "http://", "http:", ""]: + assert _errors(_net(), {"website": website}) == [{ + "field": "website", "rule": "format", "message": "'website' must be an absolute URI", + "expected": "uri", "received": website, + }] + + +def test_strict_inet_accepts_ip_literals_only() -> None: + for ip in ["192.168.0.1", "::1", "2001:db8::1", "::ffff:1.2.3.4"]: + assert _rules(_net(), {"sourceIp": ip}) == [] + assert _errors(_net(), {"sourceIp": "192.168.01.1"}) == [{ + "field": "sourceIp", "rule": "format", "message": "'sourceIp' must be an IPv4 or IPv6 address", + "expected": "inet", "received": "192.168.01.1", + }] + assert _rules(_net(), {"sourceIp": "192.168.0.1\n"}) == ["sourceIp:format"] + + +def test_lenient_accepts_any_string() -> None: + assert _rules(_net(), {"citationUrl": "not a url", "reportedIp": "example.com"}) == [] + + +def test_non_string_uri_or_inet_is_a_type_failure_lenient_or_not() -> None: + assert _rules(_net(), {"website": 5, "reportedIp": 5}) == ["website:type", "reportedIp:type"] + + +# ── assigned primary key ───────────────────────────────────────────────────── + + +def _ledger(generation: str | None = None, **code_attrs: object) -> MetaObject: + pk: dict = {"name": "pk", "@fields": "code"} + if generation is not None: + pk["@generation"] = generation + return _load(_string("code", **code_attrs), _string("label"), {"identity.primary": pk}, name="Ledger") + + +def test_assigned_pk_is_required_whatever_required_says() -> None: + assert _errors(_ledger(), {"label": "x"}) == [{"field": "code", "rule": "required", "message": "'code' is required"}] + assert _rules(_ledger(), {"code": "L-1"}) == [] + + +def test_generated_pk_is_not_demanded() -> None: + assert _rules(_ledger("increment"), {}) == [] + assert _rules(_ledger("uuid"), {}) == [] + + +def test_pk_with_a_default_may_be_omitted() -> None: + assert _rules(_ledger(default="L-0"), {}) == [] + + +def test_partial_mode_leaves_an_absent_pk_alone_but_rejects_a_present_null() -> None: + assert _rules(_ledger(), {}, partial=True) == [] + assert _rules(_ledger(), {"code": None}, partial=True) == ["code:required"] + + +def test_assigned_pk_is_presence_only() -> None: + assert _rules(_ledger(), {"code": ""}) == [] + + +# ── value objects ──────────────────────────────────────────────────────────── + + +def _with_address(is_array: bool = False) -> MetaObject: + address = {"object.value": {"name": "Address", "children": [ + _string("city", required=True), _string("zip", maxLength=5), + ]}} + field: dict = {"field.object": {"name": "address", "@objectRef": "Address"}} + if is_array: + field["field.object"]["isArray"] = True + return _load(field, name="Person", extra=(address,)) + + +def test_value_object_members_are_validated_with_a_dotted_label() -> None: + assert _errors(_with_address(), {"address": {"zip": "123456"}}) == [ + {"field": "address.city", "rule": "required", "message": "'city' is required"}, + {"field": "address.zip", "rule": "length", "message": "'zip' must be at most 5 chars (got 6)", + "expected": {"max": 5}, "received": 6}, + ] + + +def test_value_object_is_validated_in_full_inside_a_partial_update() -> None: + assert _rules(_with_address(), {"address": {}}, partial=True) == ["address.city:required"] + + +def test_value_object_must_be_an_object() -> None: + assert _errors(_with_address(), {"address": "x"}) == [{ + "field": "address", "rule": "type", "message": "'address' must be a Address object", + "expected": "Address", "received": "string", + }] + + +def test_value_object_array_elements_are_indexed() -> None: + e = _with_address(is_array=True) + assert _rules(e, {"address": [{"city": "A"}, {}]}) == ["address[1].city:required"] + assert _errors(e, {"address": {"city": "A"}}) == [{ + "field": "address", "rule": "type", "message": "'address' must be an array of Address", + "expected": "array", "received": "object", + }] + assert _errors(e, {"address": [None]})[0]["received"] == "null" + + +# ── inheritance (ADR-0039) ─────────────────────────────────────────────────── + + +def test_constraints_inherited_through_extends_are_enforced() -> None: + base = {"object.entity": {"name": "Base", "isAbstract": True, "children": [ + _with(_string("title", required=True, maxLength=3), {"validator.regex": {"@pattern": "[a-z]+"}}), + ]}} + doc = {"metadata.root": {"package": "p", "children": [ + base, {"object.entity": {"name": "Post", "extends": "Base", "children": []}}, + ]}} + result = MetaDataLoader.from_string(json.dumps(doc)) + assert not result.errors, [str(e) for e in result.errors] + post = next(c for c in result.root.children() if isinstance(c, MetaObject) and c.name == "Post") + assert _rules(post, {}) == ["title:required"] + assert _rules(post, {"title": "ABCD"}) == ["title:length", "title:regex"] + + +# ── ObjectManager.validate (standalone, no DB hit) ─────────────────────────── + + +def test_object_manager_validate_returns_the_runner_result() -> None: + from metaobjects.runtime import ObjectManager, PostgresDriver + + doc = {"metadata.root": {"package": "p", "children": [ + {"object.entity": {"name": "Post", "children": [_string("title", required=True)]}}, + ]}} + result = MetaDataLoader.from_string(json.dumps(doc)) + assert not result.errors + # validate() never touches the driver, so no connection is needed. + om = ObjectManager(result.root, PostgresDriver(None)) # type: ignore[arg-type] + assert om.validate("Post", {"title": "ok"}).ok is True + assert [e.to_dict() for e in om.validate("Post", {}).errors] == [ + {"field": "title", "rule": "required", "message": "'title' is required"}, + ] + + +# ── cross-runner parity details ────────────────────────────────────────────── + + +def test_package_qualified_object_ref_picks_the_object_in_that_package() -> None: + from metaobjects.loader.meta_data_loader import InMemoryStringSource + + def doc(pkg: str, child: dict) -> str: + return json.dumps({"metadata.root": {"package": pkg, "children": [child]}}) + + result = MetaDataLoader().load([InMemoryStringSource(text) for text in ( + doc("shipping", {"object.value": {"name": "Address", "children": [_string("zip", required=True)]}}), + doc("billing", {"object.value": {"name": "Address", "children": [_string("city", required=True)]}}), + doc("orders", {"object.entity": {"name": "Order", "children": [ + {"field.object": {"name": "addr", "@objectRef": "billing::Address"}}, + ]}}), + )]) + assert not result.errors, [str(e) for e in result.errors] + order = next(c for c in result.root.children() if c.name == "Order") + assert _rules(order, {"addr": {"city": "NYC"}}) == [] + assert _rules(order, {"addr": {"zip": "12345"}}) == ["addr.city:required"] + + +def test_bare_object_ref_resolves_in_the_declaring_entitys_own_package() -> None: + from metaobjects.loader.meta_data_loader import InMemoryStringSource + + def doc(pkg: str, child: dict) -> str: + return json.dumps({"metadata.root": {"package": pkg, "children": [child]}}) + + # shipping loads first, so a first-match-of-that-name scan would bind its Address. + result = MetaDataLoader().load([InMemoryStringSource(text) for text in ( + doc("shipping", {"object.value": {"name": "Address", "children": [_string("zip", required=True)]}}), + doc("billing", {"object.entity": {"name": "Order", "children": [ + {"field.object": {"name": "addr", "@objectRef": "Address"}}, + ]}}), + doc("billing", {"object.value": {"name": "Address", "children": [_string("city", required=True)]}}), + )]) + assert not result.errors, [str(e) for e in result.errors] + order = next(c for c in result.root.children() if c.name == "Order") + assert _rules(order, {"addr": {"city": "NYC"}}) == [] + assert _rules(order, {"addr": {"zip": "12345"}}) == ["addr.city:required"] + + +def test_numbers_in_messages_print_as_javascript_prints_them() -> None: + from metaobjects.runtime.validator_runner import _js_number + + # Each pair is (value, what a JS template literal prints for it). + for value, printed in [ + (0, "0"), (-1, "-1"), (2.0, "2"), (-0.0, "0"), (1.5, "1.5"), (0.1, "0.1"), + (1e-5, "0.00001"), (1e-6, "0.000001"), (1e-7, "1e-7"), (1.5e-7, "1.5e-7"), + (123456789.125, "123456789.125"), (1e21, "1e+21"), (1.5e22, "1.5e+22"), (1e20, "100000000000000000000"), + (float("inf"), "Infinity"), (float("-inf"), "-Infinity"), (float("nan"), "NaN"), + ("5", "5"), + ]: + assert _js_number(value) == printed, value + + +def test_a_small_value_prints_as_javascript_prints_it() -> None: + # Validator bounds are int-typed in the metamodel, so only the VALUE can be fractional. + e = _load({"field.double": {"name": "ratio", "children": [{"validator.numeric": {"@min": 1}}]}}) + assert _errors(e, {"ratio": 0.000001})[0]["message"] == "'ratio' must be at least 1 (got 0.000001)" + assert _errors(e, {"ratio": 1e-7})[0]["message"] == "'ratio' must be at least 1 (got 1e-7)" diff --git a/server/typescript/packages/integration-tests/test/validation-conformance-runtime.test.ts b/server/typescript/packages/integration-tests/test/validation-conformance-runtime.test.ts new file mode 100644 index 000000000..dad24a467 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/validation-conformance-runtime.test.ts @@ -0,0 +1,49 @@ +// Validation-conformance RUN-TIME runner (TS). +// +// The sibling `validation-conformance.test.ts` gates the GENERATED Zod schema. This one +// runs the same corpus through `runValidators` — the metadata-driven run-time runner the +// ObjectManager uses — so the two TS enforcement surfaces cannot drift, and asserts the +// exact failure list pinned in `runtime-errors.json`. The Python `run_validators` runner +// asserts the same file, which is what keeps the two run-time runners identical in +// structure and message text and not merely in verdict. + +import { describe, test, expect, beforeAll } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { runValidators, type ValidationFailure } from "@metaobjectsdev/runtime-ts"; +import { VALIDATION_DIR } from "../src/paths.ts"; +import { loadMetadataFile } from "../src/load-metadata.ts"; +import { loadCases, DEFAULT_VALIDATION_ENTITY } from "../src/validation-cases.ts"; + +const expectedErrors = ( + JSON.parse(readFileSync(join(VALIDATION_DIR, "runtime-errors.json"), "utf8")) as { + errors: Record; + } +).errors; + +let root: MetaRoot; + +beforeAll(async () => { + root = await loadMetadataFile(join(VALIDATION_DIR, "meta.json")); +}); + +describe("validation conformance — TS run-time runner (runValidators)", () => { + const cases = loadCases(); + + for (const c of cases) { + test(c.name, () => { + const entityName = c.entity ?? DEFAULT_VALIDATION_ENTITY; + const entity = root.findObject(entityName); + if (!entity) throw new Error(`case "${c.name}" names unknown entity ${entityName}`); + const result = runValidators(entity, c.payload); + expect(result.ok, `case "${c.name}": ${JSON.stringify(result)}`).toBe(c.expectValid); + if (!result.ok) expect(result.errors).toEqual(expectedErrors[c.name] ?? []); + }); + } + + test("runtime-errors.json pins exactly the rejected cases", () => { + const rejected = cases.filter((c) => !c.expectValid).map((c) => c.name).sort(); + expect(Object.keys(expectedErrors).sort()).toEqual(rejected); + }); +}); diff --git a/server/typescript/packages/runtime-ts/src/index.ts b/server/typescript/packages/runtime-ts/src/index.ts index 5c57f1e2c..d33c5d43f 100644 --- a/server/typescript/packages/runtime-ts/src/index.ts +++ b/server/typescript/packages/runtime-ts/src/index.ts @@ -12,7 +12,8 @@ export type { Filter, FilterValue, QueryOpts } from "./query-builder.js"; export type { FieldViewSpec, EntityViewSpec } from "./view.js"; -export type { ValidationResult } from "./validator-runner.js"; +export { runValidators } from "./validator-runner.js"; +export type { ValidationResult, RunValidatorsOpts } from "./validator-runner.js"; export type { ValidationFailure } from "./errors.js"; export { RuntimeError, diff --git a/server/typescript/packages/runtime-ts/src/net-format.ts b/server/typescript/packages/runtime-ts/src/net-format.ts new file mode 100644 index 000000000..5e845d4ed --- /dev/null +++ b/server/typescript/packages/runtime-ts/src/net-format.ts @@ -0,0 +1,37 @@ +// Run-time format checks for `field.uri` and `field.inet` (#234, ADR-0037 — Contract A). +// +// These are deliberately explicit, engine-neutral rules rather than `new URL()` or a +// platform IP parser: the Python `run_validators` runner applies the SAME patterns, so the +// two run-time runners agree on every input and not only on the pinned +// validation-conformance probe set. + +// The IPv4 and IPv6 literal patterns the generated Zod schema uses +// (`codegen-ts/src/templates/net-regex.ts`) — no hostnames, no CIDR, no padding, no +// leading-zero octet; IPv6 includes the embedded-IPv4 tails. +const IPV4_RE = + /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +const IPV6_RE = + /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/; + +/** True when `value` is an IPv4 or IPv6 literal. */ +export function isInetLiteral(value: string): boolean { + return IPV4_RE.test(value) || IPV6_RE.test(value); +} + +// Leading/trailing C0 controls and spaces, which a URL parser strips before parsing. +const URI_PAD_RE = /^[\u0000- ]+|[\u0000- ]+$/g; +// scheme ":" remainder — the remainder may hold anything, including a newline. +const URI_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:([\s\S]*)$/; +const URI_AUTHORITY_END_RE = /[/?#]/; + +/** + * True when `value` is an absolute URI: a scheme, a non-empty remainder and — when the + * remainder opens an authority with `//` — a non-empty authority. Padding is stripped first. + */ +export function isAbsoluteUri(value: string): boolean { + const rest = URI_SCHEME_RE.exec(value.replace(URI_PAD_RE, ""))?.[1]; + if (rest === undefined || rest.length === 0) return false; + if (!rest.startsWith("//")) return true; + const authority = rest.slice(2).split(URI_AUTHORITY_END_RE)[0] ?? ""; + return authority.length > 0; +} diff --git a/server/typescript/packages/runtime-ts/src/validator-runner.ts b/server/typescript/packages/runtime-ts/src/validator-runner.ts index f7f0cf141..fc929de3f 100644 --- a/server/typescript/packages/runtime-ts/src/validator-runner.ts +++ b/server/typescript/packages/runtime-ts/src/validator-runner.ts @@ -1,7 +1,7 @@ // Pure function: NEVER throws. ObjectManager wraps a non-ok result in a ValidationError on writes; // om.validate() returns the result directly. -import { isMetaRoot, type MetaData } from "@metaobjectsdev/metadata"; +import { isMetaObject, isMetaRoot, resolveObjectRef, type MetaData } from "@metaobjectsdev/metadata"; import { TYPE_FIELD, TYPE_VALIDATOR, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, @@ -10,10 +10,14 @@ import { FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT, FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, FIELD_ATTR_OBJECT_REF, - PACKAGE_SEPARATOR, OBJECT_SUBTYPE_VALUE, + OBJECT_SUBTYPE_VALUE, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_PATTERN, + VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, + FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_ATTR_LENIENT, + IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, GENERATION_INCREMENT, GENERATION_UUID, } from "@metaobjectsdev/metadata"; import type { ValidationFailure } from "./errors.js"; +import { isAbsoluteUri, isInetLiteral } from "./net-format.js"; export type ValidationResult = | { ok: true } @@ -56,6 +60,7 @@ export function runValidators( opts: RunValidatorsOpts = {}, ): ValidationResult { const errors: ValidationFailure[] = []; + const assignedPk = assignedPkFieldNames(entity); // Effective children so a TPH subtype validates inherited base fields too. for (const field of entity.children()) { @@ -70,7 +75,11 @@ export function runValidators( const required = isRequired(field); // ADR-0039: effective attr — @default may be inherited via extends. const hasDefault = field.attr(FIELD_ATTR_DEFAULT) !== undefined; - if (required && (value === undefined || value === null)) { + // An ASSIGNED primary key must be supplied whatever @required says: nothing else can + // produce the value. That is presence only — the non-empty-string floor below stays + // tied to a DECLARED required, as in the generated InsertSchema. + const mustBePresent = required || assignedPk.has(field.name); + if (mustBePresent && (value === undefined || value === null)) { if (opts.partial && !present) continue; // A @default exempts a required field only when it is ABSENT (the DB fills // it on insert / an omitted patch key is untouched). It does NOT rescue an @@ -117,6 +126,7 @@ export function runValidators( continue; } const elements: unknown[] = field.resolvedIsArray() ? (value as unknown[]) : [value]; + if (field.resolvedIsArray()) errors.push(...arraySizeErrors(field, elements.length)); elements.forEach((el, i) => { if (typeof el !== "object" || el === null || Array.isArray(el)) { errors.push({ @@ -152,6 +162,7 @@ export function runValidators( }); continue; } + errors.push(...arraySizeErrors(field, value.length)); value.forEach((el, i) => { if (el === null || el === undefined) return; errors.push(...scalarErrors(field, el, false, `${field.name}[${i}]`)); @@ -166,8 +177,7 @@ export function runValidators( } /** Resolve a `field.object`'s `@objectRef` to its value-object MetaData by walking - * to the tree root. The ref may be a bare name or a `pkg::Name` FQN. Mirrors the - * extract-object resolver. Returns undefined when unresolvable OR when the target + * to the tree root. The ref may be a bare name or a `pkg::Name` FQN. Returns undefined when unresolvable OR when the target * is not an `object.value` (→ no VO recursion). Cross-port parity: C#/Java/Kotlin * gate the recursion on the ref being a value object, so a `field.object @objectRef` * pointing at a non-value object validates identically (skipped) on every port. @@ -180,11 +190,13 @@ function resolveVoRef(field: MetaData): MetaData | undefined { // class check fails for a real root and every VO reference silently stops // resolving, skipping nested value-object validation with no error. if (!isMetaRoot(root)) return undefined; - let target = root.findObject(ref); - if (target === undefined) { - const sep = ref.lastIndexOf(PACKAGE_SEPARATOR); - if (sep >= 0) target = root.findObject(ref.slice(sep + PACKAGE_SEPARATOR.length)); - } + // ADR-0042 — the SINGLE object-ref resolver every ref site shares: an FQN matches + // its resolution key exactly; a bare ref resolves in the DECLARING owner's package + // (an inherited field resolves in the package that declared it), then a root-level + // object — never a same-named object in some other package. + const owner = field.parent ?? root; + const referrerPkg = owner.package ?? owner.fileDefaultPackage ?? ""; + const target = resolveObjectRef(root, ref, referrerPkg).node; return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined; } @@ -197,32 +209,87 @@ function isRequired(field: MetaData): boolean { return false; } -function resolveMaxLength(field: MetaData): number | undefined { - // ADR-0039: effective — @maxLength and a length-validator may be inherited via extends. - const attr = field.attr(FIELD_ATTR_MAX_LENGTH); - if (typeof attr === "number") return attr; +/** Primary-identity field names the CALLER must supply: the identity carries no + * store-side `@generation` (increment / uuid). A `@default` on the field still lets the + * caller omit it — the `hasDefault` exemption at the call site covers that. */ +function assignedPkFieldNames(entity: MetaData): Set { + // isMetaObject, not `instanceof` — see resolveVoRef. + const primary = isMetaObject(entity) ? entity.primaryIdentity() : undefined; + if (primary === undefined) return new Set(); + // ADR-0039: effective — an identity may be inherited via extends. + const generation = primary.attr(IDENTITY_ATTR_GENERATION); + if (generation === GENERATION_INCREMENT || generation === GENERATION_UUID) return new Set(); + const fields = primary.attr(IDENTITY_ATTR_FIELDS); + if (Array.isArray(fields)) return new Set(fields.map(String)); + return typeof fields === "string" ? new Set([fields]) : new Set(); +} + +interface Bounds { min?: number; max?: number } + +/** The `@min` / `@max` of the field's validators of one subtype (last authored wins). */ +function validatorBounds(field: MetaData, validatorSubType: string): Bounds { + const bounds: Bounds = {}; + // ADR-0039: effective — a validator and its bounds may be inherited via extends. for (const child of field.children()) { - if (child.type !== TYPE_VALIDATOR) continue; - if (child.subType !== VALIDATOR_SUBTYPE_LENGTH) continue; + if (child.type !== TYPE_VALIDATOR || child.subType !== validatorSubType) continue; + const min = child.attr(VALIDATOR_ATTR_MIN); const max = child.attr(VALIDATOR_ATTR_MAX); - if (typeof max === "number") return max; + if (typeof min === "number") bounds.min = min; + if (typeof max === "number") bounds.max = max; } - return undefined; + return bounds; } -function resolveMinLength(field: MetaData): number | undefined { - // ADR-0039: effective — a length-validator may be inherited via extends. +/** String length bounds. Max is strictest-wins across `@maxLength` and every + * `validator.length @max` (FR-036 A3). Min is the authored `validator.length @min`, + * undefined when none was authored. */ +function lengthBounds(field: MetaData): Bounds { + const bounds: Bounds = {}; + // ADR-0039: effective — @maxLength and a length-validator may be inherited via extends. + const attr = field.attr(FIELD_ATTR_MAX_LENGTH); + if (typeof attr === "number") bounds.max = attr; for (const child of field.children()) { - if (child.type !== TYPE_VALIDATOR) continue; - if (child.subType !== VALIDATOR_SUBTYPE_LENGTH) continue; + if (child.type !== TYPE_VALIDATOR || child.subType !== VALIDATOR_SUBTYPE_LENGTH) continue; const min = child.attr(VALIDATOR_ATTR_MIN); - if (typeof min === "number") return min; + const max = child.attr(VALIDATOR_ATTR_MAX); + if (typeof min === "number") bounds.min = min; + if (typeof max === "number") bounds.max = bounds.max === undefined ? max : Math.min(bounds.max, max); + } + return bounds; +} + +/** `validator.array @min/@max` — element-count bounds on an array field of any element type. */ +function arraySizeErrors(field: MetaData, size: number): ValidationFailure[] { + const { min, max } = validatorBounds(field, VALIDATOR_SUBTYPE_ARRAY); + const errors: ValidationFailure[] = []; + if (min !== undefined && size < min) { + errors.push({ + field: field.name, rule: "array", + message: `'${field.name}' must have at least ${min} items (got ${size})`, + expected: { min }, received: size, + }); } + if (max !== undefined && size > max) { + errors.push({ + field: field.name, rule: "array", + message: `'${field.name}' must have at most ${max} items (got ${size})`, + expected: { max }, received: size, + }); + } + return errors; +} + +/** The comparable numeric value of a type-checked numeric field value. An int64 arrives + * as a number, a bigint or a base-10 integer string; the string compares as an integer. */ +function comparable(value: unknown): number | bigint | undefined { + if (typeof value === "number" || typeof value === "bigint") return value; + if (typeof value === "string" && INT64_STRING_RE.test(value)) return BigInt(value); return undefined; } function checkType(subType: string, value: unknown): string | null { - if (subType === FIELD_SUBTYPE_STRING || subType === FIELD_SUBTYPE_UUID) { + if (subType === FIELD_SUBTYPE_STRING || subType === FIELD_SUBTYPE_UUID + || subType === FIELD_SUBTYPE_URI || subType === FIELD_SUBTYPE_INET) { if (typeof value !== "string") return `expected string`; } else if (NUMERIC_FIELD_SUBTYPES.has(subType)) { if (typeof value !== "number") return `expected number`; @@ -256,8 +323,7 @@ function scalarErrors(field: MetaData, value: unknown, required: boolean, label: return errors; } - const maxLen = resolveMaxLength(field); - const minLen = resolveMinLength(field); + const { min: minLen, max: maxLen } = lengthBounds(field); if (typeof value === "string") { if (maxLen !== undefined && value.length > maxLen) { errors.push({ @@ -268,11 +334,11 @@ function scalarErrors(field: MetaData, value: unknown, required: boolean, label: received: value.length, }); } - // FR-036 Pin 1: a @required string is non-empty. The effective floor is - // max(@min, 1) so the runtime OM rejects "" for a required string exactly as - // the generated Zod InsertSchema (.min(1)) does — the two enforcement surfaces - // stay in lockstep. A non-required field keeps its authored @min. - const effectiveMin = Math.max(minLen ?? 0, required ? 1 : 0); + // FR-036 Pin 1: a @required string is non-empty by default (an implicit floor of 1), + // but an explicitly authored `validator.length @min` is ALWAYS authoritative over that + // floor (#224 / ADR-0044): `@min: 0` opts back to presence-only. Same rule as the + // generated Zod InsertSchema, so the two enforcement surfaces stay in lockstep. + const effectiveMin = minLen !== undefined ? minLen : required ? 1 : 0; if (effectiveMin > 0 && value.length < effectiveMin) { errors.push({ field: label, @@ -316,5 +382,44 @@ function scalarErrors(field: MetaData, value: unknown, required: boolean, label: }); } } + + // validator.numeric @min/@max — inclusive value bounds on a numeric field. + if (NUMERIC_FIELD_SUBTYPES.has(field.subType) || INT64_FIELD_SUBTYPES.has(field.subType)) { + const num = comparable(value); + const { min, max } = validatorBounds(field, VALIDATOR_SUBTYPE_NUMERIC); + if (num !== undefined && min !== undefined && num < min) { + errors.push({ + field: label, rule: "numeric", + message: `'${label}' must be at least ${min} (got ${value})`, + expected: { min }, received: value, + }); + } + if (num !== undefined && max !== undefined && num > max) { + errors.push({ + field: label, rule: "numeric", + message: `'${label}' must be at most ${max} (got ${value})`, + expected: { max }, received: value, + }); + } + } + + // field.uri / field.inet — the strict format contract, unless @lenient opts out. + // ADR-0039: effective — @lenient may be inherited via extends. + if (typeof value === "string" && field.attr(FIELD_ATTR_LENIENT) !== true) { + if (field.subType === FIELD_SUBTYPE_URI && !isAbsoluteUri(value)) { + errors.push({ + field: label, rule: "format", + message: `'${label}' must be an absolute URI`, + expected: "uri", received: value, + }); + } + if (field.subType === FIELD_SUBTYPE_INET && !isInetLiteral(value)) { + errors.push({ + field: label, rule: "format", + message: `'${label}' must be an IPv4 or IPv6 address`, + expected: "inet", received: value, + }); + } + } return errors; } diff --git a/server/typescript/packages/runtime-ts/test/object-manager.test.ts b/server/typescript/packages/runtime-ts/test/object-manager.test.ts index 696f31681..83eab1426 100644 --- a/server/typescript/packages/runtime-ts/test/object-manager.test.ts +++ b/server/typescript/packages/runtime-ts/test/object-manager.test.ts @@ -470,6 +470,8 @@ describe("ObjectManager — validate (standalone, no DB hit)", () => { const om2 = new ObjectManager({ metadata: makeRoot([post]), driver: inMemoryDriver({}) }); const r = om2.validate("Post", {}); expect(r.ok).toBe(false); - if (!r.ok) expect(r.errors[0]?.field).toBe("title"); + // `id` is an ASSIGNED primary key (no @generation), so it is reported too — the same + // presence rule om.create() enforces through resolveIdentity. + if (!r.ok) expect(r.errors.map((e) => e.field)).toEqual(["id", "title"]); }); }); diff --git a/server/typescript/packages/runtime-ts/test/validator-runner.test.ts b/server/typescript/packages/runtime-ts/test/validator-runner.test.ts index 31dfc14be..145896384 100644 --- a/server/typescript/packages/runtime-ts/test/validator-runner.test.ts +++ b/server/typescript/packages/runtime-ts/test/validator-runner.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test"; import type { MetaData } from "@metaobjectsdev/metadata"; -import { TypeId, TYPE_OBJECT, TYPE_FIELD, TYPE_VALIDATOR, +import { TypeId, TYPE_OBJECT, TYPE_FIELD, TYPE_VALIDATOR, TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, + FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_DOUBLE, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, @@ -269,3 +270,258 @@ describe("runValidators — scalar arrays", () => { } }); }); + +// ── Rules the validation-conformance corpus requires of the run-time runner ────────────── + +function fieldOf(subType: string, name: string, attrs: Record = {}): MetaData { + const f = meta(new TypeId(TYPE_FIELD, subType), name); + for (const [k, v] of Object.entries(attrs)) f.setAttr(k, v); + return f; +} + +function validatorOf(subType: string, attrs: Record = {}): MetaData { + const v = meta(new TypeId(TYPE_VALIDATOR, subType), subType); + for (const [k, val] of Object.entries(attrs)) v.setAttr(k, val); + return v; +} + +function entityWith(...fields: MetaData[]): MetaData { + return makeEntity((e) => { for (const f of fields) e.addChild(f); }); +} + +function errorsOf(e: MetaData, data: Record, opts = {}) { + const r = runValidators(e, data, opts); + return r.ok ? [] : r.errors; +} + +describe("runValidators — validator.numeric", () => { + const score = () => { + const f = fieldOf(FIELD_SUBTYPE_INT, "score"); + f.addChild(validatorOf(VALIDATOR_SUBTYPE_NUMERIC, { min: 0, max: 100 })); + return entityWith(f); + }; + + test("bounds are inclusive", () => { + expect(errorsOf(score(), { score: 0 })).toEqual([]); + expect(errorsOf(score(), { score: 100 })).toEqual([]); + }); + + test("below @min → numeric failure with the bound and the value", () => { + expect(errorsOf(score(), { score: -1 })).toEqual([{ + field: "score", rule: "numeric", message: "'score' must be at least 0 (got -1)", + expected: { min: 0 }, received: -1, + }]); + }); + + test("above @max → numeric failure", () => { + expect(errorsOf(score(), { score: 101 })).toEqual([{ + field: "score", rule: "numeric", message: "'score' must be at most 100 (got 101)", + expected: { max: 100 }, received: 101, + }]); + }); + + test("an int64 numeric string is compared as an integer and echoed as given", () => { + const f = fieldOf(FIELD_SUBTYPE_LONG, "total"); + f.addChild(validatorOf(VALIDATOR_SUBTYPE_NUMERIC, { min: 10 })); + const e = entityWith(f); + expect(errorsOf(e, { total: "9223372036854775807" })).toEqual([]); + expect(errorsOf(e, { total: 12n })).toEqual([]); + expect(errorsOf(e, { total: "5" })).toEqual([{ + field: "total", rule: "numeric", message: "'total' must be at least 10 (got 5)", + expected: { min: 10 }, received: "5", + }]); + }); + + test("validator.numeric on a string field is ignored", () => { + const f = fieldOf(FIELD_SUBTYPE_STRING, "code"); + f.addChild(validatorOf(VALIDATOR_SUBTYPE_NUMERIC, { min: 5 })); + expect(errorsOf(entityWith(f), { code: "1" })).toEqual([]); + }); +}); + +describe("runValidators — validator.array", () => { + const tags = () => { + const f = fieldOf(FIELD_SUBTYPE_STRING, "tags"); + f.isArray = true; + f.addChild(validatorOf(VALIDATOR_SUBTYPE_ARRAY, { min: 1, max: 3 })); + return entityWith(f); + }; + + test("too few elements", () => { + expect(errorsOf(tags(), { tags: [] })).toEqual([{ + field: "tags", rule: "array", message: "'tags' must have at least 1 items (got 0)", + expected: { min: 1 }, received: 0, + }]); + }); + + test("too many elements", () => { + expect(errorsOf(tags(), { tags: ["a", "b", "c", "d"] })).toEqual([{ + field: "tags", rule: "array", message: "'tags' must have at most 3 items (got 4)", + expected: { max: 3 }, received: 4, + }]); + }); + + test("element errors are still reported alongside a size failure", () => { + const errors = errorsOf(tags(), { tags: ["a", "b", "c", 4] }); + expect(errors.map((e) => `${e.field}:${e.rule}`)).toEqual(["tags:array", "tags[3]:type"]); + }); + + test("validator.array on a non-array field is ignored", () => { + const f = fieldOf(FIELD_SUBTYPE_STRING, "name"); + f.addChild(validatorOf(VALIDATOR_SUBTYPE_ARRAY, { min: 2 })); + expect(errorsOf(entityWith(f), { name: "x" })).toEqual([]); + }); +}); + +describe("runValidators — length precedence", () => { + test("@maxLength × validator.length @max is strictest-wins", () => { + const f = fieldOf(FIELD_SUBTYPE_STRING, "label", { maxLength: 8 }); + f.addChild(validatorOf(VALIDATOR_SUBTYPE_LENGTH, { max: 4 })); + const e = entityWith(f); + expect(errorsOf(e, { label: "1234" })).toEqual([]); + expect(errorsOf(e, { label: "12345" })[0]?.expected).toEqual({ max: 4 }); + }); + + test("an authored validator.length @min: 0 opts a required string out of the non-empty floor", () => { + const f = fieldOf(FIELD_SUBTYPE_STRING, "note", { required: true }); + f.addChild(validatorOf(VALIDATOR_SUBTYPE_LENGTH, { min: 0 })); + const e = entityWith(f); + expect(errorsOf(e, { note: "" })).toEqual([]); + expect(errorsOf(e, {}).map((x) => x.rule)).toEqual(["required"]); + }); + + test("length counts UTF-16 code units", () => { + const e = entityWith(fieldOf(FIELD_SUBTYPE_STRING, "icon", { maxLength: 1 })); + expect(errorsOf(e, { icon: "\u{1F600}" })[0]?.received).toBe(2); + }); +}); + +describe("runValidators — field.uri / field.inet format", () => { + const e = () => entityWith( + fieldOf(FIELD_SUBTYPE_URI, "website"), + fieldOf(FIELD_SUBTYPE_INET, "sourceIp"), + fieldOf(FIELD_SUBTYPE_URI, "citationUrl", { lenient: true }), + fieldOf(FIELD_SUBTYPE_INET, "reportedIp", { lenient: true }), + ); + + test("strict uri accepts an absolute URI, padded or not", () => { + for (const website of ["https://a.com", " https://a.com ", "mailto:a@b.com", "urn:isbn:0451450523"]) { + expect(errorsOf(e(), { website })).toEqual([]); + } + }); + + test("strict uri rejects a scheme-less value, an empty authority and a bare scheme", () => { + for (const website of ["example.com", "/path/only", "not a url", "http://", "http:", ""]) { + expect(errorsOf(e(), { website })).toEqual([{ + field: "website", rule: "format", message: "'website' must be an absolute URI", + expected: "uri", received: website, + }]); + } + }); + + test("strict inet accepts IPv4 and IPv6 literals only", () => { + for (const sourceIp of ["192.168.0.1", "::1", "2001:db8::1", "::ffff:1.2.3.4"]) { + expect(errorsOf(e(), { sourceIp })).toEqual([]); + } + expect(errorsOf(e(), { sourceIp: "192.168.01.1" })).toEqual([{ + field: "sourceIp", rule: "format", message: "'sourceIp' must be an IPv4 or IPv6 address", + expected: "inet", received: "192.168.01.1", + }]); + }); + + test("@lenient accepts any string", () => { + expect(errorsOf(e(), { citationUrl: "not a url", reportedIp: "example.com" })).toEqual([]); + }); + + test("a non-string value is a type failure, lenient or not", () => { + expect(errorsOf(e(), { website: 5, reportedIp: 5 }).map((x) => `${x.field}:${x.rule}`)) + .toEqual(["website:type", "reportedIp:type"]); + }); +}); + +describe("runValidators — assigned primary key", () => { + const ledger = (generation?: string, codeAttrs: Record = {}) => { + const pk = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "pk"); + pk.setAttr("fields", "code"); + if (generation !== undefined) pk.setAttr("generation", generation); + const e = entityWith(fieldOf(FIELD_SUBTYPE_STRING, "code", codeAttrs), fieldOf(FIELD_SUBTYPE_STRING, "label")); + e.addChild(pk); + return e; + }; + + test("is required on insert whatever @required says", () => { + expect(errorsOf(ledger(), { label: "x" })).toEqual([ + { field: "code", rule: "required", message: "'code' is required" }, + ]); + expect(errorsOf(ledger(), { code: "L-1" })).toEqual([]); + }); + + test("a generated key is not demanded", () => { + expect(errorsOf(ledger("increment"), {})).toEqual([]); + expect(errorsOf(ledger("uuid"), {})).toEqual([]); + }); + + test("a key with a @default may be omitted", () => { + expect(errorsOf(ledger(undefined, { default: "L-0" }), {})).toEqual([]); + }); + + test("partial mode leaves an absent key alone but rejects a present null", () => { + expect(errorsOf(ledger(), {}, { partial: true })).toEqual([]); + expect(errorsOf(ledger(), { code: null }, { partial: true }).map((x) => x.rule)).toEqual(["required"]); + }); + + test("a store-filled key is exempt when absent", () => { + expect(errorsOf(ledger(), {}, { storeFilled: ["code"] })).toEqual([]); + }); + + test("presence only — the non-empty floor belongs to a declared @required", () => { + expect(errorsOf(ledger(), { code: "" })).toEqual([]); + }); +}); + +describe("runValidators — value-object @objectRef resolution", () => { + test("a package-qualified ref picks the object in that package, not the first of that name", async () => { + const { MetaDataLoader, InMemoryStringSource } = await import("@metaobjectsdev/metadata"); + const file = (pkg: string, children: unknown[]) => + new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: pkg, children } })); + const r = await new MetaDataLoader().load([ + file("shipping", [{ "object.value": { name: "Address", children: [ + { "field.string": { name: "zip", "@required": true } }, + ] } }]), + file("billing", [{ "object.value": { name: "Address", children: [ + { "field.string": { name: "city", "@required": true } }, + ] } }]), + file("orders", [{ "object.entity": { name: "Order", children: [ + { "field.object": { name: "addr", "@objectRef": "billing::Address" } }, + ] } }]), + ]); + expect(r.errors).toEqual([]); + const order = r.root.objects().find((o) => o.name === "Order")!; + expect(errorsOf(order, { addr: { city: "NYC" } })).toEqual([]); + expect(errorsOf(order, { addr: { zip: "12345" } }).map((e) => e.field)).toEqual(["addr.city"]); + }); + + test("a bare ref resolves in the declaring entity's own package, not the first of that name", async () => { + const { MetaDataLoader, InMemoryStringSource } = await import("@metaobjectsdev/metadata"); + const file = (pkg: string, children: unknown[]) => + new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: pkg, children } })); + // shipping loads first, so a first-match-of-that-name scan would bind its Address. + const r = await new MetaDataLoader().load([ + file("shipping", [{ "object.value": { name: "Address", children: [ + { "field.string": { name: "zip", "@required": true } }, + ] } }]), + file("billing", [ + { "object.value": { name: "Address", children: [ + { "field.string": { name: "city", "@required": true } }, + ] } }, + { "object.entity": { name: "Order", children: [ + { "field.object": { name: "addr", "@objectRef": "Address" } }, + ] } }, + ]), + ]); + expect(r.errors).toEqual([]); + const order = r.root.objects().find((o) => o.name === "Order")!; + expect(errorsOf(order, { addr: { city: "NYC" } })).toEqual([]); + expect(errorsOf(order, { addr: { zip: "12345" } }).map((e) => e.field)).toEqual(["addr.city"]); + }); +});