Skip to content

feat(jsonschemagen): emit propertyNames from inlined-dict key slot constraints - #16

Open
jdsika wants to merge 1 commit into
mainfrom
feat/jsonschemagen-propertynames-from-key-pattern
Open

jdsika wants to merge 1 commit into
mainfrom
feat/jsonschemagen-propertynames-from-key-pattern

Conversation

@jdsika

@jdsika jdsika commented Jun 18, 2026 •

Copy link
Copy Markdown

Mirror of linkml#3716, same head. Review there; this PR tracks the fork stack.

2026-10-14: rebased onto main (bd102062b; the only conflict was appended tests) and signed off; no code change. Full compiler suite: 12,404 passed.

Summary

For an inlined-as-dict slot (inlined: true, inlined_as_list: false) whose range class
has an identifier/key slot, emit that key slot's string-applicable constraints onto
JSON Schema propertyNames. Previously these constraints were silently dropped, so the
generated schema was strictly looser than the model declares — any key string was accepted.

classes:
  Container:
    attributes:
      entries: {range: Entry, multivalued: true, inlined: true, inlined_as_list: false}
  Entry:
    attributes:
      key: {identifier: true, pattern: "^[0-9]+$"}
      val: {}
  "entries": {
    "type": "object",
    "additionalProperties": {"$ref": "#/$defs/Entry__identifier_optional"},
+   "propertyNames": {"pattern": "^[0-9]+$"}
  }

Before this change {"entries": {"banana": {...}}} validated, despite the model declaring
that identifiers are digit strings.

Why (grounded at both ends)

  • LinkML inlining semantics (https://linkml.io/linkml/schemas/inlining.html) — in the
    inlined-dict form the dictionary key is the identifier value of the range class and
    is not duplicated inside each value object. So constraints declared on the identifier/key
    slot are, by LinkML's own serialization, constraints on the object keys.
  • JSON Schema Core, draft 2019-09, §9.3.2.5 (propertyNames, Applicator vocabulary) —
    the subschema is applied to each property name, and "the value of each property name will
    be a string." propertyNames composes conjunctively with the existing
    additionalProperties value schema, so keys and values are constrained independently.

gen-json-schema emits "$schema": .../draft/2019-09/schema; propertyNames has existed
since draft-06, so it is safe for the emitted dialect.

What is emitted

A new public helper, JsonSchemaGenerator.get_key_constraints_for_slot(), filters the
existing get_value_constraints_for_slot() result down to the subset that can be satisfied
by a string:

key slot constraint propertyNames keyword
pattern — written on the slot, resolved from a structured_pattern, or inherited from the slot's range type pattern
equals_string_in enum (string values only)
equals_string (a string) const

Intentionally excluded, because object keys are always strings:

  • numeric const (equals_number) — would reject every key;
  • minimum/maximum — unsatisfiable by a string;
  • allOf (from range_expression), and the permissible values of an enum-ranged
    identifier — out of scope, no propertyNames is emitted for those.

The filtering is by value type, not by originating keyword (isinstance(..., str) for
pattern/const, all-strings for enum), so a future non-string constraint source cannot
silently produce a propertyNames schema that rejects every key.

Note on type-derived patterns

Because get_value_constraints_for_slot() also resolves the slot's type, an identifier
whose range carries a pattern — e.g. range: ncname, or a user-defined
typeof: string with a pattern — now gains a propertyNames entry even when the slot
itself declares no constraint:

"propertyNames": {"pattern": "^[a-zA-Z_][\\w.-]*$"}

This is deliberate and is the same pattern already applied to the identifier inside the
value object (Entry__identifier_optional.properties.key.pattern), so a bare key and a
redundantly repeated in-object identifier are now validated identically. The generated
schema becomes stricter only in ways the model already required.

Compatibility

propertyNames is emitted only when the key slot yields at least one string-applicable
constraint; otherwise the output is unchanged. To check this empirically I regenerated JSON
Schema for the 139 schemas under tests/linkml/test_generators/input,
tests/linkml/test_validator/input and examples/ on main and on this branch:
0 files differ. The broader suite (test_generators, test_validator, test_issues)
is also green: 2120 passed, 54 skipped, 4 xfailed.

Only the inlined-as-dict form is affected; inlined-as-list and non-inlined references are
untouched.

How was this tested?

13 new tests in tests/linkml/test_generators/test_jsonschemagen.py:

Positive

  • pattern on the key slot → propertyNames.pattern, parametrized over identifier: true
    and key: true;
  • equals_string_in → propertyNames.enum; a string equals_string → propertyNames.const;
  • structured_pattern → resolved and emitted;
  • pattern inherited from the key slot's type, parametrized over the base-implied
    ncname pattern and a user-defined type, asserting the emitted key pattern is identical
    to the one on the in-object identifier.

Behavioral (jsonschema.validate)

  • conforming keys pass and non-matching keys raise ValidationError, for pattern,
    string const and structured_pattern.

Negative controls

  • numeric const (equals_number) and numeric minimum/maximum emit no
    propertyNames, so numeric-looking string keys are not wrongly rejected;
  • an enum-ranged identifier emits no propertyNames (documented boundary);
  • an unconstrained key slot is unchanged;
  • a unit test of get_key_constraints_for_slot() directly, covering a non-string const,
    a numeric bound, and None.

test_jsonschemagen.py: 181 passed, 1 skipped, 261 subtests passed. ruff check and
ruff format --check are clean.

Notes for reviewers

  • Rebased onto current main, and the two original commits (feature + docs) squashed
    into one. The conflict in test_jsonschemagen.py was an append-at-end collision; both
    sides' tests are kept, verified by reconstructing the file from upstream plus this PR's
    162 appended lines.
  • One semantic conflict was found and fixed during the rebase: since
    bc06f0f5 (Resolve structured patterns when computing induced slots and types)
    materialize_patterns is deprecated and structured patterns resolve automatically. The
    earlier revision of this PR gated structured_pattern behind that flag; that test was
    rewritten to assert the current behaviour and no longer uses the deprecated option.

Checklist

  • My code follows the contributor guidelines
  • I have added tests that prove my fix/feature works
  • Existing tests pass locally with my changes

AI Assistance

If you used AI tools while preparing this PR, you are still the author and responsible for
understanding, verifying, and defending your submission. Please engage with reviewers
personally rather than through your agent during feedback and revisions. See our
AI Covenant for details.

@jdsika
jdsika force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch from 8a292b2 to 29f18b0 Compare June 18, 2026 08:25
@jdsika jdsika changed the title feat(jsonschemagen): emit propertyNames from inlined-dict key slot pattern feat(jsonschemagen): emit propertyNames from inlined-dict key slot constraints Jun 18, 2026
@jdsika
jdsika force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch 2 times, most recently from 908094b to b49cce8 Compare June 18, 2026 13:45
@jdsika
jdsika force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch from d981f6b to db6fd2b Compare July 3, 2026 13:06
@rmessaou
rmessaou force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch from c33cc3f to 5add490 Compare July 8, 2026 08:49
@jdsika jdsika self-assigned this Jul 11, 2026
@jdsika
jdsika force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch 2 times, most recently from 0ccabd7 to 41c9f93 Compare September 11, 2026 14:02
@jdsika
jdsika force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch from 41c9f93 to 8fac490 Compare October 2, 2026 11:48
…nstraints

For an inlined-as-dict slot whose range class has an identifier/key slot, render the
key slot's string-applicable constraints onto JSON Schema propertyNames (draft-06+)
instead of dropping them. In the inlined-dict form the mapping key is the identifier
value, so the key slot's constraints constrain the keys. JSON object keys are always
strings, so only pattern, enum (equals_string_in) and a string const (equals_string)
are emitted; numeric minimum/maximum, numeric const (equals_number) and allOf are
excluded -- a numeric const would otherwise reject every key. structured_pattern is
honored when materialize_patterns is enabled, consistent with value patterns.
Backward compatible: emitted only when a string-applicable key constraint applies.

Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>

Includes the documentation for the emitted propertyNames constraint.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the feat/jsonschemagen-propertynames-from-key-pattern branch from 8fac490 to a228736 Compare October 14, 2026 07:19
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.

1 participant