From baff3259344a668008710840d49831590d7ab44c Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 5 Oct 2026 11:12:35 +0200 Subject: [PATCH] fix(generators): enforce range_expression on each value alike in SHACL and JSON Schema gen-shacl ignored a slot's range_expression, and gen-json-schema read it with the two-valued builder of rules, which ignores cardinalities, the range of a condition, and the slot_usage and aliases of the value class. Both generators now read it as a class-level expression: each value must satisfy it as an instance of the range class satisfies a class-level expression. In SHACL it is an sh:node next to the range's sh:class. On a class alternative of any_of it applies to that alternative, and in a slot condition it takes the form of the condition. SHACL skips an expression it cannot express with a warning, as it skips a class-level expression. gen-json-schema applied the value constraints of a slot inlined as a dict to the dict instead of to each value, which only a range_expression showed. The compact forms of such a dict, {id: value} and {id: null}, stay out of its reach. Signed-off-by: jdsika --- docs/generators/json-schema.rst | 5 + docs/generators/shacl.rst | 71 ++- docs/schemas/advanced.md | 2 + .../src/linkml/generators/jsonschemagen.py | 66 ++- .../linkml/src/linkml/generators/shaclgen.py | 159 ++++-- .../test_boolean_slot_compliance.py | 2 +- .../test_generators/test_range_expression.py | 500 ++++++++++++++++++ tests/linkml/test_generators/test_shaclgen.py | 2 +- 8 files changed, 737 insertions(+), 70 deletions(-) create mode 100644 tests/linkml/test_generators/test_range_expression.py diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index e696d66cd7..6ca5255a4f 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -180,6 +180,11 @@ A slot condition is unknown for an absent slot unless it decides whether the slo may be absent, and an instance is invalid only when an expression is definitely false, as described under "Class-level expressions and absent slots" in :doc:`Advanced features `. +A slot's `range_expression `_ +constrains each of its values in the same way, with its conditions on the slots +of the value. For a slot inlined as a dict these are the values of the dict, +except in its compact forms ``{id: value}`` and ``{id: null}``, which the +expression can't reach. The SHACL generator reads them the same way. Inlining diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index 9322186e44..cd5c3565db 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -106,8 +106,9 @@ then adds nothing; one that only some instances belong to, because a descendant of the class has it among its ancestors, and then gives ``sh:class``; or one that none belongs to, and then gives a shape nothing conforms to. An instance belongs to its class and that class's ancestors, by ``is_a`` and by ``mixins``, so a -class that may also be another is declared as a subclass with that mixin. -Nested expressions recurse. Each entry of ``slot_conditions`` gives an +class that may also be another is declared as a subclass with that mixin. For a +type or an enum, ``is_a`` gives its value constraint. Nested expressions recurse. +Each entry of ``slot_conditions`` gives an ``sh:property`` whose path is that of the slot as induced for the class, so ``slot_usage`` applies: @@ -120,7 +121,9 @@ Nested expressions recurse. Each entry of ``slot_conditions`` gives an * ``equals_string`` and ``equals_string_in`` give ``sh:in``; on an enum slot the values are the permissible values as the enum renders them, the IRI of their ``meaning`` where they have one; -* ``range`` gives the same class, type or enum constraint as a slot's range. +* ``range`` gives the same class, type or enum constraint as a slot's range; +* ``range_expression`` gives an ``sh:node``, as described under + `Range Expressions`_. A shape may have at most one value of ``sh:minInclusive``, ``sh:maxInclusive`` or ``sh:in``, and of ``sh:pattern``, whose component also takes ``sh:flags`` @@ -170,12 +173,70 @@ slot's range. An operator whose members use anything else is skipped as a whole and logged as a warning, because leaving out one member would change what the operator -admits. That covers, for example, ``has_member`` or a slot-level ``any_of`` -inside a slot condition, a condition on a name that is not a slot, a condition +admits. That covers, for example, ``has_member``, ``equals_expression`` or a +slot-level ``any_of`` inside a slot condition, a condition on a name that is not a slot, a condition on the identifier slot (the node's IRI rather than a property), and ``equals_string`` on a slot whose range does not hold strings. +Range Expressions +^^^^^^^^^^^^^^^^^ + +A slot's `range_expression `__ +constrains each of its values as a class-level expression constrains an +instance: its conditions constrain the slots of the value as induced for the +range class, and a value violates it only when it is definitely false. It +becomes an ``sh:node`` (`SHACL §4.7.1 +`__) on the property shape, +next to the range's ``sh:class``, so it constrains only the values of this slot, +and matching it doesn't make a node an instance of the range. The JSON Schema +generator reads it the same way. + +.. code-block:: yaml + + classes: + Document: + slots: [license] + slot_usage: + license: + range_expression: + slot_conditions: + category: + required: true + equals_string: license + Resource: + slots: [category] + slots: + license: + range: Resource + category: + range: string + +.. code-block:: turtle + + ex:Document a sh:NodeShape ; + sh:property [ sh:path ex:license ; + sh:class ex:Resource ; + sh:node [ sh:property [ sh:path ex:category ; + sh:minCount 1 ; + sh:in ( "license" ) ] ] ; + ... ] ; + ... + +A class alternative in a slot's ``any_of`` can carry its own +``range_expression``, and so can a slot condition, where the expression takes +the form of the condition: under ``sh:not``, its "definitely true" form. For +values that aren't instances of a class, an expression can combine types and +enums with ``is_a``, for example ``any_of: [{is_a: OpenLicense}, {is_a: +ProprietaryLicense}]``; such values have no slots for a condition to constrain. +An expression that uses anything SHACL can't express (see above) is skipped and +logged as a warning. + +A value that is a reference is checked against its description in the data +graph, as ``sh:class`` is. The JSON Schema generator can't check it, because +there the value is just an identifier. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index 2f173f2443..052b01ed21 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -92,6 +92,8 @@ A `Sample` without `status` is valid: the condition is unknown, so `none_of` isn | `none_of: [label = A]` | valid | invalid | valid | | `exactly_one_of: [label = A, note = B]` | invalid | valid | invalid | +A slot's [range_expression](https://w3id.org/linkml/range_expression) constrains each value of the slot in the same way: its conditions constrain the slots of the value, as induced for the slot's range class. In a slot condition, a `range_expression` is part of the condition, so under `none_of` it must hold definitely. + [Rules](#rules) are read differently: their preconditions require their slots, and so do their postconditions unless the rule is `open_world`. ### Unions as ranges diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 8086896e2b..98ed281927 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -2,7 +2,7 @@ import json import logging import os -from copy import deepcopy +from copy import copy, deepcopy from dataclasses import dataclass, field from typing import Any, cast @@ -658,7 +658,7 @@ def handle_class(self, cls: ClassDefinition) -> None: _CLASS_OPERATOR_KEYWORDS = {"any_of": "anyOf", "all_of": "allOf", "exactly_one_of": "oneOf"} def get_subschema_for_class_operator( - self, cls: ClassDefinition, operator: str, members: list[AnonymousClassExpression], definite: bool + self, cls: ClassDefinition | None, operator: str, members: list[AnonymousClassExpression], definite: bool ) -> JsonSchema: """The subschema of the class-level boolean expression *operator* over *members*, for *cls*. @@ -684,16 +684,18 @@ def get_subschema_for_class_operator( ) def get_subschema_for_class_expression( - self, cls: ClassDefinition, expr: AnonymousClassExpression, definite: bool + self, cls: ClassDefinition | None, expr: AnonymousClassExpression, definite: bool ) -> JsonSchema: """The subschema of one member *expr* of a class-level boolean expression of *cls*. Each slot condition constrains the slot as induced for *cls*, so - ``slot_usage`` applies. Its values must satisfy its value operators - and its ``range``; the number of values must lie within + ``slot_usage`` applies. Its values must satisfy its value operators, + its ``range`` and its ``range_expression``, the latter in the form of + *expr*; the number of values must lie within :func:`~linkml.generators.common.class_expression.value_bounds`, where an empty list or dict counts as no value. *definite* selects the form, as in - :meth:`get_subschema_for_class_operator`. + :meth:`get_subschema_for_class_operator`. *cls* is ``None`` for values + that are not instances of a class. """ subschema = JsonSchema() conjuncts: list[JsonSchema] = [] @@ -703,7 +705,17 @@ def get_subschema_for_class_expression( prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) - values = self.get_subschema_for_slot(condition, omit_type=True, include_null=False) + value_condition = condition + if condition.range_expression is not None: + # translated below, in the form of *expr* + value_condition = copy(condition) + value_condition.range_expression = None + values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) + if condition.range_expression is not None: + expression = self.get_subschema_for_range_expression( + condition.range or slot.range, condition.range_expression, definite + ) + values = JsonSchema({"allOf": [values, expression]}) if values else expression lower, upper = value_bounds(condition, definite) if slot.multivalued and self._inlined_as_dict(slot): # the values of a slot inlined as a dict are the values of the dict @@ -742,11 +754,17 @@ def get_subschema_for_class_expression( conjuncts.append(self.get_subschema_for_class_operator(cls, operator, members, definite)) if expr.is_a is not None: # `is_a: ` requires an instance of the expression to be an instance of . Every - # instance of *cls* is one, or none is, unless is a descendant of *cls*: then, in - # JSON, only the instance's structure tells, so it must match the definition of . - membership = class_membership(self.schemaview, cls.name, expr.is_a) + # instance of *cls* is one, or none is, unless some are through a descendant of *cls*: + # then, in JSON, only the instance's structure tells, so it must match the definition + # of . For a type or an enum, or a value that is no class instance, the value + # constraint applies. + sv = self.schemaview + is_class = expr.is_a in sv.all_classes() + membership = class_membership(sv, cls.name, expr.is_a) if is_class and cls is not None else None if membership is None: - conjuncts.append(self.get_subschema_for_slot(AnonymousSlotExpression(range=expr.is_a, inlined=True))) + conjuncts.append( + self.get_subschema_for_slot(AnonymousSlotExpression(range=expr.is_a, inlined=is_class)) + ) elif not membership: conjuncts.append(JsonSchema({"not": {}})) if conjuncts: @@ -761,13 +779,27 @@ def _inlined_as_dict(self, slot: SlotDefinition) -> bool: and get_range_associated_slots(self.schemaview, slot.range)[0] is not None ) - def _class_expression_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + def _class_expression_slot(self, cls: ClassDefinition | None, slot_name: str) -> SlotDefinition | None: """The slot a condition of a class expression of *cls* names, as induced for *cls*, if any.""" sv = self.schemaview - if slot_name in sv.class_slots(cls.name): + if cls is not None and slot_name in sv.class_slots(cls.name): return sv.induced_slot(slot_name, cls.name) return sv.get_slot(slot_name) + def get_subschema_for_range_expression( + self, value_range: str | None, expression: AnonymousClassExpression, definite: bool + ) -> JsonSchema: + """The subschema a value of range *value_range* must satisfy under *expression*. + + A ``range_expression`` constrains each value as a class-level expression + constrains an instance (:meth:`get_subschema_for_class_expression`): its + conditions constrain the slots of the value as induced for the class + *value_range*, and *definite* selects the form. + """ + sv = self.schemaview + value_class = sv.get_class(value_range) if value_range in sv.all_classes() else None + return self.get_subschema_for_class_expression(value_class, expression, definite) + def get_subschema_for_anonymous_class( self, cls: AnonymousClassExpression, properties_required: bool = False ) -> None | JsonSchema: @@ -938,7 +970,7 @@ def get_value_constraints_for_slot(self, slot: SlotDefinition | AnonymousSlotExp constraints.add_keyword("enum", slot.equals_string_in) if slot.range_expression: - subschema = self.get_subschema_for_anonymous_class(slot.range_expression) + subschema = self.get_subschema_for_range_expression(slot.range, slot.range_expression, definite=False) if subschema: if "allOf" not in constraints: constraints["allOf"] = [] @@ -990,6 +1022,7 @@ def get_subschema_for_slot( slot_is_boolean = any([slot.any_of, slot.all_of, slot.exactly_one_of, slot.none_of]) typ = None + inlined_as_dict = False if not omit_type: typ, fmt, reference = self.get_type_info_for_slot_subschema(slot) if slot_is_inlined: @@ -1003,6 +1036,7 @@ def get_subschema_for_slot( # if the range class has an ID and the slot is not inlined as a list, then we need to consider # various inlined as dict formats if range_id_slot is not None and not slot.inlined_as_list: + inlined_as_dict = True # At a minimum, the inlined dict can have keys (additionalProps) that are IDs # and the values are the range class but possibly omitting the ID. additionalProps = [JsonSchema.ref_for(reference, identifier_optional=True)] @@ -1078,6 +1112,10 @@ def get_subschema_for_slot( prop["items"].update(all_element_constraints) if any_element_constraints: prop["contains"] = any_element_constraints + elif inlined_as_dict: + # The values of the slot are the values of the dict. + if own_constraints: + prop["additionalProperties"] = JsonSchema({"allOf": [prop["additionalProperties"], own_constraints]}) else: prop.update(own_constraints) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index f1ee237952..11e670e5c5 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -224,6 +224,7 @@ def as_graph(self) -> Graph: self._class_expressions_added: set[tuple[URIRef, str, str]] = set() self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} + self._range_expression_problems: dict[tuple[str, str], list[str]] = {} for c in sv.all_classes(imports=not self.exclude_imports).values(): def shape_pv(p, v): @@ -359,47 +360,28 @@ def prop_pv_text(p, v): range_list = [] for any in s.any_of: r = any.range - if r in all_classes: - class_node = BNode() + branch = BNode() - def cl_node_pv(p, v): - if v is not None: - g.add((class_node, p, v)) + def branch_pv(p, v): + if v is not None: + g.add((branch, p, v)) - self._add_class(cl_node_pv, r) - range_list.append(class_node) + if r in all_classes: + self._add_class(branch_pv, r) elif r in sv.all_types(): - t_node = BNode() - - def t_node_pv(p, v): - if v is not None: - g.add((t_node, p, v)) - - self._add_type(t_node_pv, r) - range_list.append(t_node) + self._add_type(branch_pv, r) elif r in sv.all_enums(): - en_node = BNode() - - def en_node_pv(p, v): - if v is not None: - g.add((en_node, p, v)) - - self._add_enum(g, en_node_pv, r) - range_list.append(en_node) + self._add_enum(g, branch_pv, r) else: - st_node = BNode() - - def st_node_pv(p, v): - if v is not None: - g.add((st_node, p, v)) - - add_simple_data_type(st_node_pv, r) - range_list.append(st_node) + add_simple_data_type(branch_pv, r) # Propagate pattern constraint to the branch node. # A branch may combine range + pattern (e.g. range: string # with pattern: "^...") or specify pattern alone (no range). if any.pattern: - g.add((range_list[-1], SH.pattern, Literal(any.pattern))) + branch_pv(SH.pattern, Literal(any.pattern)) + if any.range_expression is not None: + self._add_range_expression(g, branch_pv, c, s, r, any.range_expression) + range_list.append(branch) Collection(g, or_node, range_list) else: prop_pv_literal(SH.hasValue, s.equals_number) @@ -425,6 +407,8 @@ def st_node_pv(p, v): if s.pattern: prop_pv(SH.pattern, Literal(s.pattern)) + if s.range_expression is not None: + self._add_range_expression(g, prop_pv, c, s, s.range, s.range_expression) if s.annotations and self.include_annotations: self._add_annotations(prop_pv, s) @@ -439,6 +423,7 @@ def st_node_pv(p, v): self._add_rules(g, class_uri_with_suffix, c) self._report_class_expression_problems() + self._report_range_expression_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" @@ -462,7 +447,16 @@ def st_node_pv(p, v): {"required", "value_presence", "minimum_cardinality", "maximum_cardinality", "exact_cardinality"} ) _SLOT_CONDITION_VALUE_FIELDS = frozenset( - {"minimum_value", "maximum_value", "pattern", "equals_string", "equals_string_in", "equals_number", "range"} + { + "minimum_value", + "maximum_value", + "pattern", + "equals_string", + "equals_string_in", + "equals_number", + "range", + "range_expression", + } ) _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS @@ -529,9 +523,10 @@ def _add_class_expressions(self, g: Graph, shape_uri: URIRef, cls: ClassDefiniti Every member becomes an anonymous node shape: ``is_a`` gives ``sh:class`` for a class that some instances of *cls* belong to through a descendant, - nothing for *cls* or an ancestor, which every instance belongs to, and a + nothing for *cls* or an ancestor, which every instance belongs to, a shape nothing conforms to for any other class - (:func:`~linkml.generators.common.class_expression.class_membership`); + (:func:`~linkml.generators.common.class_expression.class_membership`), + and the value constraint of a type or an enum; each slot condition gives an ``sh:property`` on the path of the slot as induced for *cls* (so ``slot_usage`` applies), and nested expressions recurse. @@ -585,7 +580,7 @@ def _add_logical_constraint( self, g: Graph, subject: URIRef | BNode, - cls: ClassDefinition, + cls: ClassDefinition | None, operator: str, members: list[AnonymousClassExpression], definite: bool, @@ -611,7 +606,7 @@ def _add_logical_constraint( g.add((subject, predicate, list_node)) def _class_expression_shape( - self, g: Graph, cls: ClassDefinition, expr: AnonymousClassExpression, definite: bool + self, g: Graph, cls: ClassDefinition | None, expr: AnonymousClassExpression, definite: bool ) -> BNode: """Build the anonymous node shape for one class expression *expr*, in its "definitely true" form with *definite*, otherwise its "not false" form.""" @@ -626,13 +621,16 @@ def node_pv(p, v): if expr.description is not None: node_pv(RDFS.comment, Literal(expr.description, lang=self._resolve_language(expr))) if expr.is_a is not None: - # Every instance of *cls* is an instance of ``is_a``, or none is, unless ``is_a`` is a - # descendant of *cls*: only then does the node's type decide. - membership = class_membership(self.schemaview, cls.name, expr.is_a) - if membership is None: - self._add_class(node_pv, expr.is_a) - elif not membership: - node_pv(SH["not"], BNode()) # a shape nothing conforms to + if expr.is_a in self.schemaview.all_classes(): + # Every instance of *cls* is an instance of ``is_a``, or none is, unless some are + # through a descendant of *cls*: only then does the node's type decide. + membership = class_membership(self.schemaview, cls.name, expr.is_a) if cls is not None else None + if membership is None: + self._add_class(node_pv, expr.is_a) + elif not membership: + node_pv(SH["not"], BNode()) # a shape nothing conforms to + else: + self._add_range(g, node_pv, expr.is_a) for slot_name, condition in expr.slot_conditions.items(): node_pv(SH.property, self._slot_condition_shape(g, cls, slot_name, condition, definite)) for operator in self._CLASS_EXPRESSION_OPERATORS: @@ -693,6 +691,8 @@ def prop_pv(p, v): prop_pv(SH.maxInclusive, Literal(condition.equals_number)) if condition.range is not None: self._add_range(g, prop_pv, condition.range) + if condition.range_expression is not None: + prop_pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) if repeated: # Each repeated parameter in a member shape of its own: all of them hold # for every value, as they would on the property shape itself. @@ -757,18 +757,23 @@ def _is_string_range(self, r: ElementName | None) -> bool: return True return self._type_uri(r) == str(XSD.string) - def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) -> str | None: - """Return what in class expression *expr* of *cls* cannot be translated, or ``None``.""" - sv = self.schemaview + def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpression) -> str | None: + """Return what in class expression *expr* of *cls* cannot be translated, or ``None``. + + *cls* is ``None`` when *expr* constrains values that are not instances of + a class, which have no slots for a condition to constrain. + """ unknown = self._set_operator_fields(expr) - self._CLASS_EXPRESSION_FIELDS if unknown: return f"'{sorted(unknown)[0]}'" - if expr.is_a is not None and expr.is_a not in sv.all_classes(): - return f"is_a '{expr.is_a}', which is not a class" + if expr.is_a is not None and not self._is_known_range(expr.is_a): + return f"is_a '{expr.is_a}', which is not a known class, type or enum" for slot_name, condition in expr.slot_conditions.items(): unknown = self._set_operator_fields(condition) - self._SLOT_CONDITION_FIELDS if unknown: return f"'{sorted(unknown)[0]}' in the condition on slot '{slot_name}'" + if cls is None: + return f"a condition on slot '{slot_name}' of values that are not class instances" slot = self._condition_slot(cls, slot_name) if slot is None: return f"a condition on '{slot_name}', which is not a slot" @@ -778,6 +783,10 @@ def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) if condition.range is not None and not self._is_known_range(condition.range): return f"the unknown range '{condition.range}' in the condition on slot '{slot_name}'" value_range = condition.range or slot.range + if condition.range_expression is not None: + reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + if reason is not None: + return reason if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( value_range ): @@ -789,6 +798,58 @@ def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) return reason return None + def _value_class(self, value_range: ElementName | None) -> ClassDefinition | None: + """The class whose instances a range *value_range* holds, or ``None`` for any other range.""" + sv = self.schemaview + return sv.get_class(value_range) if value_range in sv.all_classes() else None + + def _range_expression_shape( + self, g: Graph, value_range: ElementName | None, expression: AnonymousClassExpression, definite: bool + ) -> BNode: + """Build the node shape a value of range *value_range* must conform to under *expression*. + + A ``range_expression`` constrains each value as a class-level expression + constrains an instance: its conditions constrain the slots of the value + as induced for the class *value_range*, and *definite* selects the form, + as in :meth:`_class_expression_shape`. ``sh:node`` (SHACL §4.7.1) + applies the shape to each value alongside the range's own constraint, + and only to the values of this slot. + """ + return self._class_expression_shape(g, self._value_class(value_range), expression, definite) + + def _add_range_expression( + self, + g: Graph, + prop_pv: Callable, + cls: ClassDefinition, + slot: SlotDefinition, + value_range: ElementName | None, + expression: AnonymousClassExpression, + ) -> None: + """Constrain each value of range *value_range* of *slot*, in the shape of *cls*, by *expression*. + + The expression is read in its "not false" form. One that cannot be + translated is skipped with a warning, as a class-level expression is. + """ + reason = self._untranslatable(self._value_class(value_range), expression) + if reason is not None: + classes = self._range_expression_problems.setdefault((slot.name, reason), []) + if cls.name not in classes: + classes.append(cls.name) + return + prop_pv(SH.node, self._range_expression_shape(g, value_range, expression, definite=False)) + + def _report_range_expression_problems(self) -> None: + """Warn once about each ``range_expression`` of a slot that is not translated, + naming the class shapes it is missing from.""" + for (slot_name, reason), classes in self._range_expression_problems.items(): + logger.warning( + "Slot %r: range_expression is not translated to SHACL, because it uses %s (in the shapes of %s).", + slot_name, + reason, + ", ".join(map(repr, classes)), + ) + def _is_known_range(self, r: ElementName) -> bool: """Whether *r* names a class, type or enum of the schema, or a built-in type.""" sv = self.schemaview diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index dc04f6f2f8..abe307c1fa 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -2903,7 +2903,7 @@ def test_range_expression_nesting(framework, data_name, instance, is_valid): core_elements=["range_expression"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA, OWL]: + if framework not in [JSON_SCHEMA, OWL, SHACL]: if not is_valid: expected_behavior = ValidationBehavior.INCOMPLETE check_data( diff --git a/tests/linkml/test_generators/test_range_expression.py b/tests/linkml/test_generators/test_range_expression.py new file mode 100644 index 0000000000..71a3ee71bb --- /dev/null +++ b/tests/linkml/test_generators/test_range_expression.py @@ -0,0 +1,500 @@ +"""A slot's ``range_expression`` constrains each of its values alike in the JSON Schema and SHACL generators. + +Each value must satisfy the expression as an instance of its range class +satisfies a class-level expression: its conditions constrain the slots of the +value as induced for that class, and an expression is violated only when it is +definitely false. The agreement tests validate the same instance with both +generated artifacts, loading it as RDF through the generated JSON-LD context. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +import pytest +import yaml +from click.testing import CliRunner +from jsonschema import Draft201909Validator +from pyshacl import validate +from rdflib import Graph, Namespace, URIRef +from rdflib.namespace import SH + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator, cli +from linkml_runtime import SchemaView + +EX = Namespace("https://example.org/context/") +PREFIX = f"@prefix ex: <{EX}> . " + + +@pytest.fixture +def schema() -> dict[str, Any]: + """A resource is constrained differently when used as a document's license.""" + return yaml.safe_load(""" +id: https://example.org/context +name: context +prefixes: + ex: https://example.org/context/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Document: + tree_root: true + slots: [license] + slot_usage: + license: + range_expression: + slot_conditions: + category: {equals_string: license, required: true} + Resource: + slots: [category, code, tags, details] + Details: + slots: [code] +slots: + license: {range: Resource, inlined: true} + category: {range: string} + code: {range: integer} + tags: {range: string, multivalued: true} + details: {range: Details, inlined: true} +enums: + Category: + permissible_values: + license: {} +""") + + +def _as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: + """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" + class_name = obj.get("@type", class_name) + typed = {**obj, "@type": class_name} + for slot in sv.class_induced_slots(class_name): + value = obj.get(slot.name) + if slot.range in sv.all_classes() and value is not None: + values = [_as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] + typed[slot.name] = values if isinstance(value, list) else values[0] + return typed + + +def _without_types(obj: Any) -> Any: + """*obj* without its ``@type`` keys.""" + if isinstance(obj, dict): + return {k: _without_types(v) for k, v in obj.items() if k != "@type"} + if isinstance(obj, list): + return [_without_types(v) for v in obj] + return obj + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str = "Document") -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*.""" + text = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) + context = json.loads(ContextGenerator(text).serialize())["@context"] + data = {"@context": context, **_as_json_ld(SchemaView(text), instance, target)} + shapes = ShaclGenerator(text, closed=False).serialize() + conforms, _, _ = validate( + Graph().parse(data=json.dumps(data), format="json-ld"), + shacl_graph=Graph().parse(data=shapes, format="turtle"), + meta_shacl=True, + ) + return Draft201909Validator(json_schema).is_valid(_without_types(instance)), conforms + + +def conforms(schema: dict[str, Any], data: str, **options: Any) -> bool: + """Whether Turtle *data* conforms to the SHACL shapes generated for *schema*, which must pass meta-SHACL.""" + shapes = ShaclGenerator(yaml.safe_dump(schema), closed=False, **options).serialize() + return validate( + data_graph=PREFIX + data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + )[0] + + +def _equals(slot: str, value: str | int) -> dict[str, Any]: + """A class expression requiring *slot* to equal *value*, unknown when *slot* is absent.""" + key = "equals_number" if isinstance(value, int) else "equals_string" + return {"slot_conditions": {slot: {key: value}}} + + +_REQUIRED_LICENSE = {"slot_conditions": {"category": {"equals_string": "license", "required": True}}} +_LICENSE_OR_CODE = [_equals("category", "license"), _equals("code", 5)] +_NO_TAGS_AND_LICENSE = { + "slot_conditions": {"tags": {"maximum_cardinality": 0}}, + "all_of": [_equals("category", "license")], +} +_TWO_TAGS = {"slot_conditions": {"tags": {"minimum_cardinality": 2}}} +_CATEGORY_RANGE = {"slot_conditions": {"category": {"range": "Category"}}} +_CODE_FROM_TWO = {"slot_conditions": {"code": {"minimum_value": 2}}} +_REQUIRED_DETAILS = {"slot_conditions": {"details": {"required": True, "range_expression": _CODE_FROM_TWO}}} +_NO_DETAILS_CODE = {"none_of": [{"slot_conditions": {"details": {"range_expression": _CODE_FROM_TWO}}}]} + + +@pytest.mark.parametrize( + "expression,value,valid", + [ + pytest.param(_REQUIRED_LICENSE, {"category": "license"}, True, id="required-match"), + pytest.param(_REQUIRED_LICENSE, {"category": "other"}, False, id="required-other"), + pytest.param(_REQUIRED_LICENSE, {}, False, id="required-absent"), + # a condition that doesn't state presence is unknown for an absent slot + pytest.param(_equals("category", "license"), {}, True, id="optional-absent"), + pytest.param(_equals("category", "license"), {"category": "other"}, False, id="optional-other"), + pytest.param({"none_of": [_equals("category", "license")]}, {}, True, id="none_of-absent"), + pytest.param({"none_of": [_equals("category", "license")]}, {"category": "license"}, False, id="none_of-match"), + pytest.param({"none_of": [_equals("category", "license")]}, {"category": "other"}, True, id="none_of-other"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {}, True, id="any_of-absent"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {"category": "other"}, True, id="any_of-false-or-unknown"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {"category": "other", "code": 1}, False, id="any_of-false"), + # exactly_one_of counts the members that are definitely true + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {}, False, id="exactly_one_of-absent"), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"category": "license"}, True, id="exactly_one_of-first"), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"code": 5}, True, id="exactly_one_of-second"), + pytest.param( + {"exactly_one_of": _LICENSE_OR_CODE}, {"category": "license", "code": 5}, False, id="exactly_one_of-both" + ), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"category": "other"}, False, id="exactly_one_of-none"), + pytest.param(_NO_TAGS_AND_LICENSE, {"tags": ["a"]}, False, id="absent-and-all_of-tags"), + pytest.param(_NO_TAGS_AND_LICENSE, {"category": "other"}, False, id="absent-and-all_of-other"), + pytest.param(_NO_TAGS_AND_LICENSE, {"category": "license"}, True, id="absent-and-all_of-match"), + pytest.param(_TWO_TAGS, {}, False, id="cardinality-absent"), + pytest.param(_TWO_TAGS, {"tags": ["a"]}, False, id="cardinality-too-few"), + pytest.param(_TWO_TAGS, {"tags": ["a", "b"]}, True, id="cardinality-enough"), + pytest.param(_CATEGORY_RANGE, {"category": "license"}, True, id="condition-range"), + pytest.param(_CATEGORY_RANGE, {"category": "other"}, False, id="condition-range-other"), + # a nested expression constrains the slots of the value's value, as induced for its range class + pytest.param(_REQUIRED_DETAILS, {"details": {"code": 2}}, True, id="nested"), + pytest.param(_REQUIRED_DETAILS, {"details": {"code": 1}}, False, id="nested-other"), + pytest.param(_REQUIRED_DETAILS, {"details": {}}, True, id="nested-absent"), + pytest.param(_REQUIRED_DETAILS, {}, False, id="nested-required"), + # under a negation, a nested expression takes its "definitely true" form + pytest.param(_NO_DETAILS_CODE, {"details": {}}, True, id="nested-in-none_of-absent"), + pytest.param(_NO_DETAILS_CODE, {"details": {"code": 3}}, False, id="nested-in-none_of-match"), + pytest.param(_NO_DETAILS_CODE, {"details": {"code": 1}}, True, id="nested-in-none_of-other"), + ], +) +@pytest.mark.parametrize("multivalued", [False, True]) +def test_range_expression_constrains_each_value( + schema: dict[str, Any], expression: dict[str, Any], value: dict[str, Any], valid: bool, multivalued: bool +) -> None: + """Both generators read a range expression alike, on a single value and on a list of values.""" + schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] = expression + schema["slots"]["license"]["multivalued"] = multivalued + assert verdicts(schema, {"license": [value] if multivalued else value}) == (valid, valid) + + +@pytest.mark.parametrize( + "values,valid", + [ + ([{"category": "license"}, {"category": "license"}], True), + ([{"category": "license"}, {"category": "other"}], False), + ([{"category": "license"}, {}], False), + ], +) +def test_range_expression_holds_for_every_value(schema: dict[str, Any], values: list[dict], valid: bool) -> None: + """Every value of a multivalued slot must satisfy the expression.""" + schema["slots"]["license"]["multivalued"] = True + assert verdicts(schema, {"license": values}) == (valid, valid) + + +@pytest.mark.parametrize( + "operator,value,valid", + [ + ("all_of", {}, True), + ("all_of", {"category": "license"}, True), + ("all_of", {"category": "other"}, False), + ("none_of", {}, True), + ("none_of", {"category": "license"}, False), + ("none_of", {"category": "other"}, True), + ], +) +def test_range_expression_in_class_expression( + schema: dict[str, Any], operator: str, value: dict[str, Any], valid: bool +) -> None: + """In a condition of a class-level expression, a range expression takes the form of the condition.""" + del schema["classes"]["Document"]["slot_usage"] + schema["classes"]["Document"][operator] = [ + {"slot_conditions": {"license": {"range_expression": _equals("category", "license")}}} + ] + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("value,valid", [({"category": "license"}, True), ({"category": "other"}, False)]) +def test_range_expression_on_union_branch(schema: dict[str, Any], value: dict[str, Any], valid: bool) -> None: + """A class alternative of a slot's ``any_of`` carries its own range expression.""" + expression = schema["classes"]["Document"].pop("slot_usage")["license"]["range_expression"] + schema["classes"]["Note"] = {"slots": ["text"]} + schema["slots"]["text"] = {"range": "string", "required": True} + schema["slots"]["license"] = { + "inlined": True, + "any_of": [{"range": "Resource", "range_expression": expression}, {"range": "Note"}], + } + # the RDF type of a value of a union is the alternative it is an instance of + assert verdicts(schema, {"license": {"@type": "Resource", **value}}) == (valid, valid) + assert verdicts(schema, {"license": {"@type": "Note", "text": "explanation"}}) == (True, True) + + +def test_union_branch_keeps_its_class(schema: dict[str, Any]) -> None: + """Satisfying the expression of a class alternative doesn't make a value an instance of that class.""" + expression = schema["classes"]["Document"].pop("slot_usage")["license"]["range_expression"] + schema["slots"]["license"] = {"inlined": True, "any_of": [{"range": "Resource", "range_expression": expression}]} + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] .') + assert not conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Details; ex:category "license"] .') + + +@pytest.mark.parametrize( + "value,valid", [({"category": "mit"}, True), ({"category": "eula"}, True), ({"category": "gpl"}, False)] +) +def test_range_expression_combining_enums(schema: dict[str, Any], value: dict[str, Any], valid: bool) -> None: + """A range expression on values that aren't class instances can combine enums.""" + del schema["classes"]["Document"]["slot_usage"] + schema["enums"] = { + "Open": {"permissible_values": {"mit": {}, "bsd": {}}}, + "Proprietary": {"permissible_values": {"eula": {}}}, + } + schema["slots"]["category"]["range_expression"] = {"any_of": [{"is_a": "Open"}, {"is_a": "Proprietary"}]} + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("in_class_expression", [False, True]) +@pytest.mark.parametrize("value,valid", [({"category": ["license"]}, True), ({"category": ["license", "x"]}, False)]) +def test_range_expression_uses_the_slots_of_the_range_class( + schema: dict[str, Any], value: dict[str, Any], valid: bool, in_class_expression: bool +) -> None: + """A condition constrains the slot as induced for the range class, whose ``slot_usage`` applies.""" + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "slot_usage": {"category": {"multivalued": True}}} + schema["slots"]["license"]["range"] = "SpecialResource" + if in_class_expression: + del schema["classes"]["Document"]["slot_usage"] + condition = {"range_expression": _equals("category", "license")} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + else: + schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] = _equals("category", "license") + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("target", ["Document", "SpecialDocument"]) +@pytest.mark.parametrize("value,valid", [({"category": "license"}, True), ({"category": "other"}, False)]) +def test_range_expression_is_inherited(schema: dict[str, Any], target: str, value: dict[str, Any], valid: bool) -> None: + """A subclass keeps the range expression its parent places on a slot.""" + schema["classes"]["SpecialDocument"] = {"is_a": "Document"} + assert verdicts(schema, {"license": value}, target=target) == (valid, valid) + + +@pytest.mark.parametrize("category,valid", [("license", True), ("other", False)]) +def test_range_expression_on_values_inlined_as_dict(schema: dict[str, Any], category: str, valid: bool) -> None: + """The values of a slot inlined as a dict are the values of the dict, not the dict itself.""" + schema["classes"]["Resource"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + schema["slots"]["license"]["multivalued"] = True + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + instance = {"license": {"r1": {"category": "license"}, "r2": {"category": category}}} + assert Draft201909Validator(json_schema).is_valid(instance) is valid + data = 'ex:doc a ex:Document; ex:license ex:r1, ex:r2 . ex:r1 a ex:Resource; ex:category "license" . ' + assert conforms(schema, data + f'ex:r2 a ex:Resource; ex:category "{category}" .') is valid + + +@pytest.mark.parametrize("category,valid", [("license", True), ("other", False)]) +def test_range_expression_in_condition_on_values_inlined_as_dict( + schema: dict[str, Any], category: str, valid: bool +) -> None: + """In a condition of a class-level expression, a range expression constrains the values of a dict.""" + del schema["classes"]["Document"]["slot_usage"] + schema["classes"]["Resource"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + schema["slots"]["license"]["multivalued"] = True + condition = {"range_expression": _equals("category", "license")} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + assert Draft201909Validator(json_schema).is_valid({"license": {"r1": {"category": category}}}) is valid + + +_COMPACT_DICT_SCHEMA = """ +id: https://example.org/compact +name: compact +prefixes: {ex: https://example.org/compact/, linkml: https://w3id.org/linkml/} +default_prefix: ex +imports: [linkml:types] +classes: + Resource: + attributes: + id: {identifier: true} + category: {range: string} + Document: + attributes: + license: + range: Resource + multivalued: true + inlined: true + range_expression: + slot_conditions: + category: {equals_string: license, required: true} +""" + + +@pytest.mark.xfail(strict=True, reason="JSON Schema can't apply a range expression to the compact forms of a dict") +@pytest.mark.parametrize("value", ["other", None], ids=["simple-dict", "null"]) +def test_range_expression_on_compact_dict_forms(value: str | None) -> None: + """The compact forms ``{id: value}`` and ``{id: null}`` of a slot inlined as a dict escape the + expression in JSON Schema, while SHACL rejects the resources they denote.""" + json_schema = json.loads(JsonSchemaGenerator(_COMPACT_DICT_SCHEMA, top_class="Document").serialize()) + assert not Draft201909Validator(json_schema).is_valid({"license": {"r1": value}}) + + +def test_range_expression_follows_the_alias(schema: dict[str, Any]) -> None: + """A condition names a slot of the value, whose alias is its JSON key and whose IRI is its RDF property.""" + schema["slots"]["category"]["alias"] = "kind" + validator = Draft201909Validator( + json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + ) + assert validator.is_valid({"license": {"kind": "license"}}) + assert not validator.is_valid({"license": {"kind": "other"}}) + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] .') + assert not conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + + +@pytest.mark.parametrize("type_statement,expected", [("a ex:Resource;", True), ("", False), ("a ex:Details;", False)]) +def test_range_expression_preserves_class_membership( + schema: dict[str, Any], type_statement: str, expected: bool +) -> None: + """Matching content cannot substitute for membership in the declared range.""" + assert conforms(schema, f'ex:doc a ex:Document; ex:license [{type_statement} ex:category "license"] .') is expected + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +@pytest.mark.parametrize("suffix", [None, "Shape"]) +@pytest.mark.parametrize("use_class_uri_names", [False, True]) +def test_range_expression_independent_of_shape_names( + schema: dict[str, Any], category: str, expected: bool, suffix: str | None, use_class_uri_names: bool +) -> None: + """The anonymous shape of a range expression works with either naming mode and a suffix.""" + data = f'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "{category}"] .' + assert conforms(schema, data, suffix=suffix, use_class_uri_names=use_class_uri_names) is expected + + +def test_range_expression_does_not_constrain_every_instance_of_the_range(schema: dict[str, Any]) -> None: + """Only the values of the constrained slot get the extra requirement.""" + assert conforms( + schema, + 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] . ' + 'ex:other a ex:Resource; ex:category "other" .', + ) + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_range_expression_on_referenced_values(schema: dict[str, Any], category: str, expected: bool) -> None: + """In RDF, a value that is a reference is constrained by its description in the data graph.""" + schema["classes"]["Resource"]["slots"].append("id") + schema["slots"]["id"] = {"identifier": True, "range": "uriorcurie"} + schema["slots"]["license"]["inlined"] = False + data = f'ex:doc a ex:Document; ex:license ex:resource . ex:resource a ex:Resource; ex:category "{category}" .' + assert conforms(schema, data) is expected + + +def test_range_expression_uses_the_condition_range(schema: dict[str, Any]) -> None: + """A range expression in a condition with a ``range`` resolves its conditions in that range class.""" + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "attributes": {"grade": {"range": "integer"}}} + del schema["classes"]["Document"]["slot_usage"] + expression = {"slot_conditions": {"grade": {"minimum_value": 2}}} + condition = {"range": "SpecialResource", "range_expression": expression} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + data = "ex:doc a ex:Document; ex:license [a ex:Resource, ex:SpecialResource; ex:grade {}] ." + assert conforms(schema, data.format(2)) + assert not conforms(schema, data.format(1)) + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_cli_enforces_range_expression(schema: dict[str, Any], tmp_path: Path, category: str, expected: bool) -> None: + """The command line generates the same shapes, without an option.""" + path = tmp_path / "context.yaml" + path.write_text(yaml.safe_dump(schema)) + result = CliRunner().invoke(cli, [str(path), "--non-closed"]) + assert result.exit_code == 0, result.output + data = PREFIX + f'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "{category}"] .' + report = validate( + data_graph=data, data_graph_format="turtle", shacl_graph=result.output, shacl_graph_format="turtle" + ) + assert report[0] is expected + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_range_expression_on_imported_slots( + schema: dict[str, Any], tmp_path: Path, category: str, expected: bool +) -> None: + """With imported shapes excluded, a condition still resolves the IRI of an imported slot.""" + imported = { + "id": "https://example.org/imported", + "name": "imported", + "default_prefix": "imp", + "prefixes": {"imp": "https://example.org/imported/"}, + "classes": {"Resource": {"class_uri": "imp:Resource", "slots": ["category"]}}, + "slots": {"category": {"slot_uri": "imp:category", "range": "string"}}, + } + (tmp_path / "imported.yaml").write_text(yaml.safe_dump(imported)) + del schema["classes"]["Resource"] + del schema["slots"]["category"] + schema["imports"].append("imported") + path = tmp_path / "context.yaml" + path.write_text(yaml.safe_dump(schema)) + shapes = ShaclGenerator(str(path), closed=False, exclude_imports=True).serialize() + graph = Graph().parse(data=shapes, format="turtle") + assert (None, SH.targetClass, URIRef("https://example.org/imported/Resource")) not in graph + data = ( + f"{PREFIX} @prefix imp: . " + f'ex:doc a ex:Document; ex:license [a imp:Resource; imp:category "{category}"] .' + ) + report = validate(data_graph=data, data_graph_format="turtle", shacl_graph=graph, meta_shacl=True) + assert report[0] is expected + + +@pytest.mark.parametrize( + "condition,reason", + [ + ({"unit": {"ucum_code": "m"}}, "'unit' in the condition on slot 'category'"), + ({"equals_expression": "{code} * 2"}, "'equals_expression' in the condition on slot 'category'"), + ], +) +def test_untranslatable_range_expression_skipped_with_warning( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture, condition: dict[str, Any], reason: str +) -> None: + """A range expression SHACL cannot express is left out of the shapes of each class, with one warning.""" + expression = schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] + expression["slot_conditions"]["category"].update(condition) + schema["classes"]["SpecialDocument"] = {"is_a": "Document"} + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + assert [r.getMessage() for r in caplog.records] == [ + f"Slot 'license': range_expression is not translated to SHACL, because it uses {reason} " + "(in the shapes of 'Document', 'SpecialDocument')." + ] + + +def test_range_expression_with_conditions_on_values_that_are_not_class_instances( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """Values of a type or an enum have no slots for a condition to constrain.""" + del schema["classes"]["Document"]["slot_usage"] + schema["slots"]["category"]["range_expression"] = {"slot_conditions": {"code": {"required": True}}} + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert verdicts(schema, {"license": {"category": "other"}}) == (True, True) + assert [r.getMessage() for r in caplog.records] == [ + "Slot 'category': range_expression is not translated to SHACL, because it uses a condition on slot 'code' " + "of values that are not class instances (in the shapes of 'Resource')." + ] + + +def test_untranslatable_range_expression_in_class_expression_skips_it( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A class-level expression whose range expression cannot be translated is skipped as a whole.""" + del schema["classes"]["Document"]["slot_usage"] + expression = {"slot_conditions": {"category": {"equals_string": "license", "unit": {"ucum_code": "m"}}}} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": {"range_expression": expression}}}] + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + assert [r.getMessage() for r in caplog.records] == [ + "Class 'Document': all_of is not translated to SHACL, because it uses " + "'unit' in the condition on slot 'category'." + ] diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index 020f756d71..e07e35e443 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -4333,7 +4333,7 @@ def test_class_expression_range_with_identifier_is_an_iri(): required: true""", ), "is_a-not-a-class": ( - "not a class", + "not a known class, type or enum", """ - is_a: Undefined - slot_conditions: