Skip to content

Commit db6fd2b

Browse files
committed
feat(jsonschemagen): emit propertyNames from inlined-dict key slot constraints
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>
1 parent a746139 commit db6fd2b

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

packages/linkml/src/linkml/generators/jsonschemagen.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,28 @@ def get_subschema_for_slot(
828828
else:
829829
typ = ["object", "null"]
830830
prop = JsonSchema({"type": typ, "additionalProperties": additionalProps})
831+
# In the inlined-dict form the mapping key *is* the value of the
832+
# range's identifier/key slot, so constraints declared on that
833+
# slot constrain the keys. Render them onto ``propertyNames``
834+
# (the JSON Schema key-constraint keyword, draft-06+) rather than
835+
# dropping them. JSON object keys are always strings (JSON Schema
836+
# Core 2019-09, 9.3.2.5), so only the string-applicable subset of
837+
# the slot's constraints is emitted: ``pattern``, ``enum``
838+
# (``equals_string_in``) and a string ``const`` (``equals_string``).
839+
# Numeric constraints (``minimum``/``maximum``, numeric ``const``
840+
# from ``equals_number``) and ``allOf`` are intentionally excluded
841+
# -- they cannot match a string key (a numeric ``const`` would
842+
# reject every key). Like value patterns, ``structured_pattern`` is
843+
# honoured only when ``materialize_patterns`` is enabled. Backward
844+
# compatible: emitted only when a key constraint applies.
845+
slot_constraints = self.get_value_constraints_for_slot(range_id_slot)
846+
key_constraints = JsonSchema(
847+
{k: slot_constraints[k] for k in ("pattern", "enum") if k in slot_constraints}
848+
)
849+
if isinstance(slot_constraints.get("const"), str):
850+
key_constraints["const"] = slot_constraints["const"]
851+
if key_constraints:
852+
prop["propertyNames"] = key_constraints
831853
self.top_level_schema.add_lax_def(reference, self.aliased_slot_name(range_id_slot))
832854
else:
833855
prop = JsonSchema.array_of(JsonSchema.ref_for(reference), include_null, required=slot.required)

tests/linkml/test_generators/test_jsonschemagen.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,3 +1090,111 @@ def test_add_lax_def_missing_required():
10901090
schema["$defs"]["NormalClass"] = {"type": "object", "properties": {"id": {}}, "required": ["id", "name"]}
10911091
schema.add_lax_def("NormalClass", "id")
10921092
assert schema["$defs"]["NormalClass__identifier_optional"]["required"] == ["name"]
1093+
1094+
1095+
def _inlined_dict_schema(key_slot_yaml: str, key_decl: str = "identifier: true", key_range: str = "string") -> str:
1096+
"""Build a schema with an inlined-as-dict slot whose key slot is configured by
1097+
``key_decl`` (``identifier: true`` or ``key: true``), ``key_range`` (the key slot
1098+
range), and ``key_slot_yaml`` (extra YAML lines for the key slot)."""
1099+
return f"""
1100+
id: https://example.org/test-key-constraints
1101+
name: test-key-constraints
1102+
prefixes:
1103+
linkml: https://w3id.org/linkml/
1104+
default_range: string
1105+
imports:
1106+
- linkml:types
1107+
classes:
1108+
Container:
1109+
tree_root: true
1110+
attributes:
1111+
entries:
1112+
range: Entry
1113+
multivalued: true
1114+
inlined: true
1115+
inlined_as_list: false
1116+
Entry:
1117+
attributes:
1118+
key:
1119+
{key_decl}
1120+
range: {key_range}
1121+
{key_slot_yaml}
1122+
val:
1123+
range: string
1124+
"""
1125+
1126+
1127+
@pytest.mark.parametrize("key_decl", ["identifier: true", "key: true"])
1128+
def test_inlined_dict_key_pattern_emits_property_names(key_decl):
1129+
"""A literal ``pattern`` on the inlined-dict key slot (identifier or key) must be
1130+
rendered onto ``propertyNames``."""
1131+
schema = _inlined_dict_schema(' pattern: "^[0-9]+$"', key_decl=key_decl)
1132+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1133+
assert generated["properties"]["entries"]["propertyNames"] == {"pattern": "^[0-9]+$"}
1134+
1135+
1136+
def test_inlined_dict_key_enum_emits_property_names():
1137+
"""``equals_string_in`` on the key slot becomes an ``enum`` constraint on keys."""
1138+
schema = _inlined_dict_schema(" equals_string_in:\n - a\n - b")
1139+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1140+
assert generated["properties"]["entries"]["propertyNames"] == {"enum": ["a", "b"]}
1141+
1142+
1143+
def test_inlined_dict_no_key_constraint_emits_no_property_names():
1144+
"""No constraint on the key slot -> no ``propertyNames`` (unchanged behavior)."""
1145+
schema = _inlined_dict_schema("")
1146+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1147+
assert "propertyNames" not in generated["properties"]["entries"]
1148+
1149+
1150+
def test_inlined_dict_key_structured_pattern_requires_materialization():
1151+
"""``structured_pattern`` on the key slot is honored only when patterns are
1152+
materialized -- identical to how value patterns are handled."""
1153+
schema = _inlined_dict_schema(
1154+
" structured_pattern:\n syntax: '^[0-9]+$'\n interpolated: true"
1155+
)
1156+
without = json.loads(JsonSchemaGenerator(schema).serialize())
1157+
assert "propertyNames" not in without["properties"]["entries"]
1158+
1159+
with_materialized = json.loads(JsonSchemaGenerator(schema, materialize_patterns=True).serialize())
1160+
assert with_materialized["properties"]["entries"]["propertyNames"] == {"pattern": "^[0-9]+$"}
1161+
1162+
1163+
def test_inlined_dict_property_names_rejects_nonmatching_keys():
1164+
"""Behavioral check: keys matching the pattern validate; non-matching keys fail."""
1165+
schema = _inlined_dict_schema(' pattern: "^[0-9]+$"')
1166+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1167+
1168+
jsonschema.validate({"entries": {"0": {"val": "x"}}}, generated)
1169+
with pytest.raises(jsonschema.ValidationError):
1170+
jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated)
1171+
1172+
1173+
def test_inlined_dict_key_string_const_emits_property_names():
1174+
"""A string ``const`` (``equals_string``) on the key slot becomes a key const."""
1175+
schema = _inlined_dict_schema(" equals_string: fixed")
1176+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1177+
assert generated["properties"]["entries"]["propertyNames"] == {"const": "fixed"}
1178+
jsonschema.validate({"entries": {"fixed": {"val": "x"}}}, generated)
1179+
with pytest.raises(jsonschema.ValidationError):
1180+
jsonschema.validate({"entries": {"other": {"val": "x"}}}, generated)
1181+
1182+
1183+
def test_inlined_dict_key_numeric_const_is_not_emitted():
1184+
"""A numeric ``const`` (``equals_number``) must NOT be emitted onto propertyNames:
1185+
keys are always strings, so a numeric const would reject every key. The keys are
1186+
left unconstrained instead."""
1187+
schema = _inlined_dict_schema(" equals_number: 5", key_range="integer")
1188+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1189+
assert "propertyNames" not in generated["properties"]["entries"]
1190+
# numeric-looking string keys still validate (unconstrained)
1191+
jsonschema.validate({"entries": {"5": {"val": "x"}}}, generated)
1192+
jsonschema.validate({"entries": {"anything": {"val": "x"}}}, generated)
1193+
1194+
1195+
def test_inlined_dict_key_numeric_bounds_are_not_emitted():
1196+
"""Numeric ``minimum``/``maximum`` on the key slot are no-ops on string keys and
1197+
must not be emitted (they would be misleading clutter)."""
1198+
schema = _inlined_dict_schema(" minimum_value: 1\n maximum_value: 10", key_range="integer")
1199+
generated = json.loads(JsonSchemaGenerator(schema).serialize())
1200+
assert "propertyNames" not in generated["properties"]["entries"]

0 commit comments

Comments
 (0)