Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/generators/json-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,72 @@ will generate:
LinkML also supports `Structured patterns <https://w3id.org/linkml/structured_pattern>`_, these are
compiled down to patterns during JSON Schema generation.

Dictionary key constraints (propertyNames)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

A multivalued, inlined slot whose range class has an identifier slot is
compiled to a JSON object keyed by that identifier (see *Inlining* above).
When the identifier slot carries string-applicable constraints, they are
emitted as a `propertyNames <https://json-schema.org/understanding-json-schema/reference/object.html#property-names>`_
schema on the container object, so the *keys* of the dictionary are validated,
not just the values:

.. code-block:: yaml

slots:
tags:
range: Tag
multivalued: true
inlined: true
uid:
identifier: true
pattern: "^(0|[1-9][0-9]*)$"

generates on the container:

.. code-block:: json

"tags": {
"additionalProperties": {"$ref": "#/$defs/Tag"},
"propertyNames": {"pattern": "^(0|[1-9][0-9]*)$"},
"type": "object"
}

The constraints carried over from the key slot are the ones applicable to JSON
Schema strings, because object keys are always strings (`JSON Schema Core
2019-09, §9.3.2.5 <https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.section.9.3.2.5>`_):

* ``pattern`` -- whether written directly on the slot, resolved from a
``structured_pattern``, or inherited from the slot's ``range`` type (for
example an identifier with ``range: ncname``, or a user-defined type that
declares a ``pattern``);
* ``equals_string_in``, emitted as ``enum``;
* a string ``equals_string``, emitted as ``const``.

The emitted key pattern is always the same one that applies to the identifier
*inside* the value object, so a key and a redundantly repeated in-object
identifier are now validated identically.

Numeric constraints -- ``minimum_value``/``maximum_value``, and the numeric
``const`` produced by ``equals_number`` -- are deliberately **not** carried
over: they cannot be satisfied by a string key, and a numeric ``const`` would
reject every key. The ``allOf`` produced by a ``range_expression``, and the
permissible values of an ``enum``-ranged identifier, are likewise out of scope.

``propertyNames`` composes conjunctively with ``additionalProperties``, so keys
and values are constrained independently. It is emitted only when the key slot
actually carries one of the constraints listed above; an unconstrained key slot
produces exactly the same output as before.

.. note::

Because type-level patterns are included, an identifier slot whose range is
``ncname`` (or another pattern-bearing type) gains a ``propertyNames``
entry even if the slot itself declares no constraint. The generated schema
becomes stricter, but only in ways the model already required: data whose
keys satisfy the declared identifier type is unaffected.


Rules
^^^^^

Expand Down
39 changes: 39 additions & 0 deletions packages/linkml/src/linkml/generators/jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,40 @@ def get_value_constraints_for_slot(self, slot: SlotDefinition | AnonymousSlotExp

return constraints

def get_key_constraints_for_slot(self, slot: SlotDefinition | None) -> JsonSchema:
"""Constraints applicable to the *keys* of an inlined-as-dict slot.

In the inlined-dict form the mapping key *is* the value of the range class's
identifier/key slot (https://linkml.io/linkml/schemas/inlining.html) and is not
repeated inside the value object, so constraints declared on that slot -- or
inherited from its type -- are constraints on the object keys. The result is
intended for JSON Schema ``propertyNames``, which composes conjunctively with
``additionalProperties``.

JSON object keys are always strings (JSON Schema Core 2019-09, 9.3.2.5), so only
the string-applicable subset of :meth:`get_value_constraints_for_slot` is
returned: ``pattern`` (including a resolved ``structured_pattern`` and a pattern
inherited from the slot's type), a string ``const`` (``equals_string``) and a
string ``enum`` (``equals_string_in``). Numeric constraints -- ``minimum`` and
``maximum``, and the numeric ``const`` produced by ``equals_number`` -- and the
``allOf`` produced by ``range_expression`` are excluded: they cannot be satisfied
by a string key, and a numeric ``const`` would reject *every* key.

:param slot: the identifier or key slot of the range class
:return: a schema for ``propertyNames``; empty when the key is unconstrained
"""
constraints = self.get_value_constraints_for_slot(slot)

key_constraints = JsonSchema()
for keyword in ("pattern", "const"):
value = constraints.get(keyword)
if isinstance(value, str):
key_constraints[keyword] = value
enum_values = constraints.get("enum")
if isinstance(enum_values, list) and all(isinstance(value, str) for value in enum_values):
key_constraints["enum"] = enum_values
return key_constraints

def get_subschema_for_slot(
self,
slot: SlotDefinition | AnonymousSlotExpression,
Expand Down Expand Up @@ -858,6 +892,11 @@ def get_subschema_for_slot(
else:
typ = ["object", "null"]
prop = JsonSchema({"type": typ, "additionalProperties": additionalProps})
# The dict keys are the range's identifier/key values, so that
# slot's string-applicable constraints constrain the keys.
key_constraints = self.get_key_constraints_for_slot(range_id_slot)
if key_constraints:
prop["propertyNames"] = key_constraints
self.top_level_schema.add_lax_def(reference, self.aliased_slot_name(range_id_slot))
else:
prop = JsonSchema.array_of(JsonSchema.ref_for(reference), include_null, required=slot.required)
Expand Down
162 changes: 162 additions & 0 deletions tests/linkml/test_generators/test_jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -1626,3 +1626,165 @@ def test_generate_array_error_complex_unbounded_shape(array_error_complex_unboun
_ = JsonSchemaGenerator(
array_error_complex_unbounded,
).generate()


def _inlined_dict_schema(
key_slot_yaml: str,
key_decl: str = "identifier: true",
key_range: str = "string",
extra_yaml: str = "",
) -> str:
"""Build a schema with an inlined-as-dict slot whose key slot is configured by
``key_decl`` (``identifier: true`` or ``key: true``), ``key_range`` (the key slot
range), and ``key_slot_yaml`` (extra YAML lines for the key slot). ``extra_yaml`` is
appended at the top level, for declaring extra ``types``/``enums``."""
return f"""
id: https://example.org/test-key-constraints
name: test-key-constraints
prefixes:
linkml: https://w3id.org/linkml/
default_range: string
imports:
- linkml:types
{extra_yaml}
classes:
Container:
tree_root: true
attributes:
entries:
range: Entry
multivalued: true
inlined: true
inlined_as_list: false
Entry:
attributes:
key:
{key_decl}
range: {key_range}
{key_slot_yaml}
val:
range: string
"""


@pytest.mark.parametrize("key_decl", ["identifier: true", "key: true"])
def test_inlined_dict_key_pattern_emits_property_names(key_decl):
"""A literal ``pattern`` on the inlined-dict key slot (identifier or key) must be
rendered onto ``propertyNames``."""
schema = _inlined_dict_schema(' pattern: "^[0-9]+$"', key_decl=key_decl)
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert generated["properties"]["entries"]["propertyNames"] == {"pattern": "^[0-9]+$"}


def test_inlined_dict_key_enum_emits_property_names():
"""``equals_string_in`` on the key slot becomes an ``enum`` constraint on keys."""
schema = _inlined_dict_schema(" equals_string_in:\n - a\n - b")
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert generated["properties"]["entries"]["propertyNames"] == {"enum": ["a", "b"]}


def test_inlined_dict_no_key_constraint_emits_no_property_names():
"""No constraint on the key slot -> no ``propertyNames`` (unchanged behavior)."""
schema = _inlined_dict_schema("")
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert "propertyNames" not in generated["properties"]["entries"]


def test_inlined_dict_key_structured_pattern_emits_property_names():
"""``structured_pattern`` on the key slot is resolved and rendered onto
``propertyNames`` -- identical to how value patterns are handled."""
schema = _inlined_dict_schema(" structured_pattern:\n syntax: '[0-9]+'")
generated = json.loads(JsonSchemaGenerator(schema).serialize())

key_pattern = generated["$defs"]["Entry"]["properties"]["key"]["pattern"]
assert generated["properties"]["entries"]["propertyNames"] == {"pattern": key_pattern}
jsonschema.validate({"entries": {"12": {"val": "x"}}}, generated)
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated)


def test_inlined_dict_property_names_rejects_nonmatching_keys():
"""Behavioral check: keys matching the pattern validate; non-matching keys fail."""
schema = _inlined_dict_schema(' pattern: "^[0-9]+$"')
generated = json.loads(JsonSchemaGenerator(schema).serialize())

jsonschema.validate({"entries": {"0": {"val": "x"}}}, generated)
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated)


def test_inlined_dict_key_string_const_emits_property_names():
"""A string ``const`` (``equals_string``) on the key slot becomes a key const."""
schema = _inlined_dict_schema(" equals_string: fixed")
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert generated["properties"]["entries"]["propertyNames"] == {"const": "fixed"}
jsonschema.validate({"entries": {"fixed": {"val": "x"}}}, generated)
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate({"entries": {"other": {"val": "x"}}}, generated)


def test_inlined_dict_key_numeric_const_is_not_emitted():
"""A numeric ``const`` (``equals_number``) must NOT be emitted onto propertyNames:
keys are always strings, so a numeric const would reject every key. The keys are
left unconstrained instead."""
schema = _inlined_dict_schema(" equals_number: 5", key_range="integer")
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert "propertyNames" not in generated["properties"]["entries"]
# numeric-looking string keys still validate (unconstrained)
jsonschema.validate({"entries": {"5": {"val": "x"}}}, generated)
jsonschema.validate({"entries": {"anything": {"val": "x"}}}, generated)


def test_inlined_dict_key_numeric_bounds_are_not_emitted():
"""Numeric ``minimum``/``maximum`` on the key slot are no-ops on string keys and
must not be emitted (they would be misleading clutter)."""
schema = _inlined_dict_schema(" minimum_value: 1\n maximum_value: 10", key_range="integer")
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert "propertyNames" not in generated["properties"]["entries"]


@pytest.mark.parametrize(
("key_range", "extra_yaml"),
[
("ncname", ""),
("DigitString", "types:\n DigitString:\n typeof: string\n pattern: '^[0-9]+$'"),
],
ids=["base-implied-pattern", "user-defined-type-pattern"],
)
def test_inlined_dict_key_type_pattern_emits_property_names(key_range, extra_yaml):
"""A pattern inherited from the key slot's *type* constrains the identifier value just
as a slot-level pattern does, so it must reach ``propertyNames`` too. The emitted key
pattern is exactly the one applied to the identifier inside the value object, so keys
and the (optional) in-object identifier are validated identically."""
schema = _inlined_dict_schema("", key_range=key_range, extra_yaml=extra_yaml)
generated = json.loads(JsonSchemaGenerator(schema).serialize())

value_key_pattern = generated["$defs"]["Entry__identifier_optional"]["properties"]["key"]["pattern"]
assert generated["properties"]["entries"]["propertyNames"] == {"pattern": value_key_pattern}


def test_inlined_dict_key_enum_range_emits_no_property_names():
"""A key slot whose range is a LinkML *enum* is compiled to a ``$ref`` on the value
side; ``get_value_constraints_for_slot`` reports no string-applicable constraint for
it, so no ``propertyNames`` is emitted and the keys stay unconstrained."""
schema = _inlined_dict_schema(
"", key_range="Colour", extra_yaml="enums:\n Colour:\n permissible_values:\n red:\n green:"
)
generated = json.loads(JsonSchemaGenerator(schema).serialize())
assert "propertyNames" not in generated["properties"]["entries"]


def test_inlined_dict_key_constraints_helper_drops_non_string_values():
"""``get_key_constraints_for_slot`` keeps only string-applicable keywords, regardless
of which upstream constraint produced them: a numeric ``const`` or a non-string
``enum`` would reject every key, so both are dropped."""
schema = _inlined_dict_schema("")
generator = JsonSchemaGenerator(schema)
generator.generate()

slot = SlotDefinition("key", pattern="^[0-9]+$")
assert generator.get_key_constraints_for_slot(slot) == {"pattern": "^[0-9]+$"}

assert generator.get_key_constraints_for_slot(SlotDefinition("key", equals_number=5)) == {}
assert generator.get_key_constraints_for_slot(SlotDefinition("key", minimum_value=1)) == {}
assert generator.get_key_constraints_for_slot(None) == {}
Loading