@@ -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