Skip to content

Add JSON Schema draft 2019-09 and 2020-12 support - #144

Open
zmstone wants to merge 5 commits into
for-GET:masterfrom
zmstone:json-schema-draft-2019-09-2020-12
Open

Add JSON Schema draft 2019-09 and 2020-12 support#144
zmstone wants to merge 5 commits into
for-GET:masterfrom
zmstone:json-schema-draft-2019-09-2020-12

Conversation

@zmstone

@zmstone zmstone commented Jul 22, 2026

Copy link
Copy Markdown

What

Adds JSON Schema draft 2019-09 and draft 2020-12 support to jesse (for
emqx/emqx#17978), validated against
the official JSON-Schema-Test-Suite. Drafts 3/4/6 are left untouched.

Landed in three milestones (one commit each):

  • J1 — dialect plumbing + the 2019-09 "mechanical" keywords
  • J2unevaluatedProperties / unevaluatedItems (the annotation model)
  • J3 — draft 2020-12 (prefixItems, items-rest, dispatch)

Changes

  • Dispatch (jesse_schema_validator): a schema whose $schema is the
    2019-09 or 2020-12 metaschema URI (with or without a trailing #, http or
    https) routes to a new dialect module. Previously any non-3/4/6 $schema
    returned schema_unsupported.
  • jesse_validator_draft2019_09 (forked from draft6):
    dependentRequired/dependentSchemas, if/then/else,
    minContains/maxContains, $defs, local $anchor resolution, $ref
    evaluated alongside siblings (2019-09 no longer replaces the whole
    schema), and unevaluatedProperties/unevaluatedItems. format is
    annotation-only per the 2019-09 default.
  • jesse_validator_draft2020_12 (forked from 2019-09): the items
    tuple-form → prefixItems rename, items as the after-prefixItems
    applicator, with the matching evaluated-item accounting for unevaluatedItems.
  • jesse_state: $id (not legacy id) drives scope resolution under
    2019-09/2020-12; plain-name $ref fragments resolve via a new $anchor
    search (skipping $anchors buried in enum/const); an opaque evaluated
    annotation accumulator (unused by draft 3/4/6).

unevaluated* annotation model

Each schema-object evaluation carries an evaluated-set ({PropNames, ItemIdxs}).
check_value/3 resets it on entry (so cousins in separate subschemas can't
see each other's annotations); adjacent keywords and successful in-place
applicators (allOf/anyOf/oneOf/if-then-else/$ref/dependentSchemas)
contribute; not never does; child-instance recursion restores the parent set
(the uncle rule). anyOf/oneOf collect annotations from all/one passing
branch. This passes the suite's cousin/uncle/nested/in-place-sibling cases.

Safety: no silent false-acceptance

Keywords a dialect defines but jesse doesn't implement yet — $recursiveRef
(2019-09) and $dynamicRef (2020-12) — raise keyword_not_supported rather
than being silently ignored, so a schema relying on them fails loudly instead
of quietly accepting data those keywords would reject.

Test coverage (official JSON-Schema-Test-Suite)

Dialect Pass %
draft 2019-09 927 / 1003 92.4%
draft 2020-12 935 / 997 93.8%

All non-skipped assertions are green. New CT suites keep an explicit,
documented skip-list (in the suite) rather than silently dropping cases. The
skipped groups are: $recursiveRef/$dynamicRef, remote-schema fetching
(refRemote, remote-metaschema id/defs/ref cases), and in-document $id
base-URI scoping (some anchor/unknownKeyword cases) — all follow-ups.

Validation

  • make ct — all five draft suites pass (3/4/6: 105/105; 2019-09: 47/47 cases;
    2020-12: 47/47 cases; 199 CT cases total)
  • make eunit — 36/36
  • make elvis, make xref — clean
  • make dialyzer — no new warnings (only the pre-existing ct:pal noise in
    jesse_tests_util)

Follow-ups: $recursiveRef/$dynamicRef (dynamic scope stack), remote-schema
fetching, in-document $id scoping. The 1.9.0 tag / EMQX dep bump happen
after this merges.

zmstone added 5 commits July 22, 2026 15:01
Add a new dialect module jesse_validator_draft2019_09 (forked from draft6),
dispatched from the schema's $schema URI
(https://json-schema.org/draft/2019-09/schema, with or without a trailing #).

Implemented keywords:
- dependentRequired / dependentSchemas (the split of draft-06 dependencies)
- if / then / else
- minContains / maxContains (extending contains)
- $defs, local $anchor resolution (in jesse_state)
- $ref evaluated alongside sibling keywords (2019-09 no longer replaces
  the whole schema with the ref target)
- format is annotation-only (non-asserting), per the 2019-09 default

Safety: keywords the dialect defines but jesse does not yet implement
(unevaluatedProperties, unevaluatedItems, $recursiveRef) raise a
keyword_not_supported schema error instead of being silently ignored, so a
schema relying on them can never quietly accept data those keywords reject.

Wire a new CT suite jesse_tests_draft2019_09_SUITE against the official
JSON-Schema-Test-Suite submodule with an explicit, documented skip-list for
the not-yet-supported groups. 833/1003 individual draft 2019-09 tests pass;
draft3/4/6 suites and eunit are unaffected.
Milestone J2. Thread an evaluated-annotation accumulator through the draft
2019-09 recursion so unevaluated* apply only to properties/items not evaluated
by an adjacent keyword or a successful in-place applicator.

- jesse_state: add an opaque 'evaluated' field with get/set (unused by
  draft 3/4/6).
- draft2019_09: split check_value/3 into a per-schema-object entry (resets the
  evaluated set, walks keywords, then applies unevaluated*) and the keyword
  walk. properties/patternProperties/additionalProperties/items/contains record
  what they evaluate; allOf/anyOf/oneOf/if-then-else/$ref/dependentSchemas
  merge the evaluated sets of their *passing* subschemas upward; 'not' never
  contributes; child-instance recursion restores the parent's set (so cousins
  and uncles cannot see each other's annotations). anyOf/oneOf now collect
  annotations from all/one passing branch respectively.

Fix a latent jesse_json_path KVC pitfall: looking up a keyword on the empty
object (unwrapped to []) returned [] rather than not_found; unevaluated*
lookups now use a plain proplist lookup (schema_keyword/2).

draft 2019-09 JSON-Schema-Test-Suite coverage rises to 927/1003; draft3/4/6
suites and eunit unaffected.
Milestone J3. New dialect module jesse_validator_draft2020_12, forked from the
2019-09 validator and dispatched from the 2020-12 $schema URI (with or without
a trailing #).

Differences from 2019-09:
- The tuple form of "items" is now "prefixItems"; "items" is the
  single-schema applicator for the items after any "prefixItems" (replacing
  2019-09 "additionalItems"). Evaluated-item accounting for unevaluatedItems
  is adjusted accordingly.
- "$dynamicRef" hard-errors (keyword_not_supported) instead of being
  silently ignored, so it can never false-accept; "$dynamicAnchor" is an
  inert identifier. A dynamic scope stack is left for a follow-up.

Everything else (dependentRequired/dependentSchemas, if/then/else,
min/maxContains, $defs/$anchor, $ref alongside siblings, annotation-only
format, and the unevaluated* annotation model) is inherited from 2019-09.

New CT suite jesse_tests_draft2020_12_SUITE against the official
JSON-Schema-Test-Suite: 935/997 individual draft 2020-12 tests pass. All five
draft suites (3/4/6/2019-09/2020-12) and eunit pass.
Predicate that reports whether a schema's $schema dialect URI is one jesse can
validate against (drafts 3/4/6 and, partially, 2019-09/2020-12; the latter with
or without a trailing #). Lets callers reject a schema declaring an unsupported
dialect at registration time instead of accepting it and failing every
validation at run time.
@zmstone

zmstone commented Jul 22, 2026

Copy link
Copy Markdown
Author

the last commit was made because the pr was originally preped for the emqx fork.
please let me know which copyright header should be set for this repo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants