diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 9606d676cc..e98706d692 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -1,4 +1,5 @@ import logging +import math import os import re import string @@ -70,6 +71,11 @@ def _validate_message_template(template: str) -> None: ) +_PRESENT = PresenceEnum(PresenceEnum.PRESENT) +_ABSENT = PresenceEnum(PresenceEnum.ABSENT) +_UNCOMMITTED = PresenceEnum(PresenceEnum.UNCOMMITTED) + + @dataclass class _RuleSite: """A rule as it is translated for the shape of a class. @@ -181,9 +187,9 @@ class ShaclGenerator(Generator): emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. - When ``True`` (default), recognised rule patterns are translated into - SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding - ``sh:NodeShape``. Currently two patterns are recognised: + When ``True`` (default), rules are translated into SHACL-SPARQL + constraints (``sh:SPARQLConstraint``) on the corresponding + ``sh:NodeShape``. Two patterns are recognised first: * *Presence implies value* — a precondition with ``value_presence: PRESENT`` on a guard slot and a postcondition with ``equals_string`` or @@ -194,8 +200,12 @@ class ShaclGenerator(Generator): on a slot and a postcondition with ``maximum_cardinality`` on the *same* slot. - A rule that cannot be translated exactly is skipped with a warning. A - class shape carries the rules of the class's ancestors and mixins too. + Any other rule is composed from the operators its conditions use + (presence, value comparisons, ``pattern``, numeric bounds, boolean + combinations of these, ``range_expression``, ``has_member``), with one + constraint per postcondition slot. A rule that + cannot be translated exactly is skipped with a warning. A class shape + carries the rules of the class's ancestors and mixins too. See `W3C SHACL §5 `_ and `linkml/linkml#2464 `_. @@ -516,13 +526,20 @@ def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: applies to every value of the slot (``05validation.md``), so it is not this pattern. - Every emitted constraint translates its rule exactly. A rule that + * **Composed** — any other rule is composed from the operators its + conditions use: presence, ``equals_string(_in)``, ``equals_number``, + ``pattern`` and numeric bounds on each value, boolean combinations + of those, a ``range_expression`` on the slot's class, and + ``has_member``, with one constraint per postcondition slot (see + :meth:`_compose_rule_sparql`). + + Every emitted constraint translates its rule exactly. A rule that cannot be translated exactly is skipped with a warning naming the - reason: an operator combination outside these patterns, a condition on - an unknown or identifier slot, a value the target's range cannot hold - (see :meth:`_value_terms`), or ``bidirectional``. Of a rule with ``elseconditions`` the forward - (if/then) direction is emitted, exactly, and a warning reports the - else branch as not enforced. + reason: an operator the translations do not support, a condition on an + unknown or identifier slot, a value the slot's range cannot hold (see + :meth:`_value_terms`), or ``bidirectional``. Of a rule with + ``elseconditions`` the forward (if/then) direction is emitted, exactly, + and a warning reports the else branch as not enforced. A rule applies to "all members of this class" (metamodel ``rules``), so the shape of a class carries the rules of its ancestors and mixins @@ -545,23 +562,23 @@ def _add_rule(self, g: Graph, shape_uri: URIRef, site: _RuleSite) -> None: if rule.bidirectional: self._skip_rule(site, "bidirectional rules are not supported") return - sparql_query = self._rule_to_sparql(site) - if sparql_query is None: + sparql_queries = self._rule_to_sparql(site) + if sparql_queries is None: return if rule.elseconditions is not None: self._warn_rule( site, "its elseconditions are not enforced; SHACL-SPARQL generation emits the if/then direction only" ) message = Literal(rule.description, lang=self._resolve_language(rule)) if rule.description else None - if self._has_sparql_constraint(g, shape_uri, Literal(sparql_query), message): - return - - constraint = BNode() - g.add((shape_uri, SH.sparql, constraint)) - g.add((constraint, RDF.type, SH.SPARQLConstraint)) - if message is not None: - g.add((constraint, SH.message, message)) - g.add((constraint, SH.select, Literal(sparql_query))) + for sparql_query in sparql_queries: + if self._has_sparql_constraint(g, shape_uri, Literal(sparql_query), message): + continue + constraint = BNode() + g.add((shape_uri, SH.sparql, constraint)) + g.add((constraint, RDF.type, SH.SPARQLConstraint)) + if message is not None: + g.add((constraint, SH.message, message)) + g.add((constraint, SH.select, Literal(sparql_query))) @staticmethod def _has_sparql_constraint(g: Graph, shape_uri: URIRef, query: Literal, message: Literal | None) -> bool: @@ -595,7 +612,17 @@ def _report_rule_problems(self) -> None: logger.warning("%s: %s%s.", rule, problem, shapes) def _skip_rule(self, site: _RuleSite, reason: str) -> None: - """Log that the rule at *site* is not translated, and why.""" + """Log that the rule at *site* is not translated, and why. + + This replaces the problems recorded so far while translating the rule + for the shape of ``site.cls``, which describe a constraint that is not + emitted. + """ + for key, (_, classes) in list(self._rule_problems.items()): + if key[:2] == (site.owner, site.index) and site.cls.name in classes: + classes.remove(site.cls.name) + if not classes: + del self._rule_problems[key] self._warn_rule(site, f"skipped, because {reason}") # Fields on a slot condition / class expression that carry no constraint @@ -616,7 +643,12 @@ def _skip_rule(self, site: _RuleSite, reason: str) -> None: # as SPARQL boolean literals. _XSD_BOOLEAN_LEXICAL = {"true": "true", "1": "true", "false": "false", "0": "false"} - _NO_PATTERN = "its conditions match none of the translated patterns (presence implies value, exclusive value)" + # Prefixes the reason a rule is skipped when it uses an operator, or a + # combination of conditions, that no translation supports. + _NO_PATTERN = ( + "its conditions match none of the translated patterns (presence implies value, exclusive value, " + "and the compositional translation)" + ) @classmethod def _set_operator_fields( @@ -645,8 +677,8 @@ def _set_operator_fields( and not (isinstance(value, list | dict) and not value) } - def _rule_to_sparql(self, site: _RuleSite) -> str | None: - """Translate the rule at *site* to a SPARQL SELECT query. + def _rule_to_sparql(self, site: _RuleSite) -> list[str] | None: + """Translate the rule at *site* to SPARQL SELECT queries, one per constraint. Returns ``None``, after a warning naming the reason, when the rule matches no supported pattern exactly. Each pattern requires its @@ -666,9 +698,11 @@ def _rule_to_sparql(self, site: _RuleSite) -> str | None: if untranslated: self._skip_rule(site, f"its {side} use {', '.join(sorted(untranslated))}") return None + if not expression.slot_conditions: + self._skip_rule(site, f"its {side} constrain no slot") + return None if len(pre.slot_conditions) != 1 or len(post.slot_conditions) != 1: - self._skip_rule(site, self._NO_PATTERN) - return None + return self._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) ((pre_name, pre_cond),) = pre.slot_conditions.items() ((post_name, post_cond),) = post.slot_conditions.items() @@ -684,14 +718,14 @@ def _rule_to_sparql(self, site: _RuleSite) -> str | None: # the case `equals_string: "true"` on a boolean flag. if ( pre_ops == {"value_presence"} - and pre_cond.value_presence == PresenceEnum(PresenceEnum.PRESENT) + and pre_cond.value_presence == _PRESENT and post_ops in ({"equals_string"}, {"equals_string_in"}) ): values = [post_cond.equals_string] if post_ops == {"equals_string"} else list(post_cond.equals_string_in) terms = self._value_terms(site, post_slot, values) if terms is None: return None - return self._build_presence_implies_value_sparql(pre_slot, post_slot, terms, bool(site.rule.open_world)) + return [self._build_presence_implies_value_sparql(pre_slot, post_slot, terms, bool(site.rule.open_world))] # Exclusive value: "if V is one of the values of slot X, then X has at # most N values". @@ -708,10 +742,9 @@ def _rule_to_sparql(self, site: _RuleSite) -> str | None: terms = self._value_terms(site, pre_slot, [exclusive]) if terms is None: return None - return self._build_exclusive_value_sparql(pre_slot, terms[0], int(post_cond.maximum_cardinality)) + return [self._build_exclusive_value_sparql(pre_slot, terms[0], int(post_cond.maximum_cardinality))] - self._skip_rule(site, self._NO_PATTERN) - return None + return self._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) def _exclusive_value(self, condition: SlotDefinition, operators: set[str]) -> str | None: """The value V of an exclusive-value precondition, or ``None`` if *condition* is not one. @@ -724,6 +757,394 @@ def _exclusive_value(self, condition: SlotDefinition, operators: set[str]) -> st return condition.has_member.equals_string return None + # Operators the compositional translation supports on a condition: those a + # value is tested against (:meth:`_value_test`), including the boolean + # combinations of such tests, those a value's own slots are tested against + # (``range_expression``), and those stating whether the slot is present or + # some value satisfies a condition. + _BOOLEAN_OPERATORS = frozenset({"any_of", "all_of", "exactly_one_of", "none_of"}) + _TEST_OPERATORS = ( + frozenset({"equals_string", "equals_string_in", "equals_number", "minimum_value", "maximum_value", "pattern"}) + | _BOOLEAN_OPERATORS + ) + _VALUE_OPERATORS = _TEST_OPERATORS | {"range_expression"} + _CONDITION_OPERATORS = _VALUE_OPERATORS | {"required", "value_presence", "has_member"} + + # The datatypes SPARQL compares numerically: the numeric types and the types + # derived from them (SPARQL 1.1 §17.1, + # ). + _SPARQL_NUMERIC_DATATYPES = frozenset( + str(XSD[name]) + for name in ( + "integer", + "decimal", + "float", + "double", + "nonPositiveInteger", + "negativeInteger", + "long", + "int", + "short", + "byte", + "nonNegativeInteger", + "unsignedLong", + "unsignedInt", + "unsignedShort", + "unsignedByte", + "positiveInteger", + ) + ) + + def _compose_rule_sparql( + self, site: _RuleSite, pre: dict[str, SlotDefinition], post: dict[str, SlotDefinition] + ) -> list[str] | None: + """Compose the queries of a rule no named pattern matches from its slot conditions *pre* and *post*. + + The caller passes them after checking that the pre- and postconditions + set nothing else (:meth:`_rule_to_sparql`). Each precondition becomes + filters on ``$this``. Each postcondition slot gets its own query, the + union of the ways to violate that slot's condition: a rule + ``P -> A and B`` is violated exactly when ``P and not A`` or + ``P and not B`` holds. Each query selects the focus nodes that satisfy + every precondition and violate one postcondition (`SHACL §5.3.1 + `_), with the + slot as ``?path`` and the offending value where there is one (`SHACL + §5.3.2 `_). + If any condition cannot be translated, the whole rule is skipped. + + Whether a condition requires its slot is decided by :meth:`_presence`. + Its value operators and ``range_expression`` apply to every value of + the slot: a slot constraint applies to all members of a collection + (``05validation.md``), as the JSON Schema generator applies it through + ``items``. ``has_member`` requires some value to satisfy its + condition. A condition may use :data:`_CONDITION_OPERATORS`; any other + operator skips the rule, as does an untranslatable value. + """ + filters: list[str] = [] + for index, (slot_name, condition) in enumerate(pre.items()): + slot = self._rule_condition_slot(site, slot_name) + if slot is None: + return None + slot_filters = self._precondition_filters(site, slot, condition, f"?pre{index}") + if slot_filters is None: + return None + filters.extend(slot_filters) + queries: list[str] = [] + for post_name, post_cond in post.items(): + post_slot = self._rule_condition_slot(site, post_name) + if post_slot is None: + return None + violations = self._postcondition_violations(site, post_slot, post_cond, bool(site.rule.open_world)) + if violations is None: + return None + lines = list(filters) + if len(violations) == 1: + lines.extend(violations) + else: + lines.append(f"{{ {violations[0]} }}") + lines.extend(f"UNION {{ {violation} }}" for violation in violations[1:]) + body = "\n".join(f" {line}" for line in lines) + queries.append( + f"SELECT DISTINCT $this (<{self._slot_iri(post_slot)}> AS ?path) ?value WHERE {{\n{body}\n}}" + ) + return queries + + @staticmethod + def _presence(condition: SlotDefinition, default: PresenceEnum) -> PresenceEnum: + """Whether the rule *condition* requires its slot to be ``PRESENT``, ``ABSENT``, or neither (``UNCOMMITTED``). + + ``value_presence`` decides, then ``required``, then *default*, as the + JSON Schema generator decides whether a rule condition requires its + property: by default a precondition does, a postcondition unless the + rule is ``open_world`` ("the postconditions may be omitted in instance + data", metamodel), and an inner condition of a nested expression never. + """ + if condition.value_presence is not None: + return PresenceEnum(condition.value_presence) + if condition.required is not None: + return _PRESENT if condition.required else _UNCOMMITTED + return default + + def _precondition_filters( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, var: str + ) -> list[str] | None: + """The SPARQL filters that hold when *slot* of ``$this`` satisfies the precondition *condition*: + the slot is present (by default) or absent as :meth:`_presence` decides, and nothing violates it.""" + checked = self._condition_violations(site, slot, condition, "$this", var, _PRESENT, "precondition") + if checked is None: + return None + presence, violations = checked + value = f"$this <{self._slot_iri(slot)}> {var} ." + filters: list[str] = [] + if presence == _PRESENT: + filters.append(f"FILTER EXISTS {{ {value} }}") + elif presence == _ABSENT: + filters.append(f"FILTER NOT EXISTS {{ {value} }}") + return filters + [f"FILTER NOT EXISTS {{ {violation} }}" for violation in violations] + + def _postcondition_violations( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, open_world: bool + ) -> list[str] | None: + """The patterns by which ``$this`` violates the postcondition *condition* on *slot*. + + A pattern matching an offending value binds it to ``?value``. With + *open_world* "the postconditions may be omitted in instance data" + (metamodel ``open_world``), so the slot is not required by default. + """ + default = _UNCOMMITTED if open_world else _PRESENT + checked = self._condition_violations(site, slot, condition, "$this", "?value", default, "postcondition") + if checked is None: + return None + presence, violations = checked + presence_violation = self._presence_violation("$this", slot, "?value", presence) + violations = [presence_violation, *violations] if presence_violation else violations + if not violations: + self._skip_rule(site, f"its postcondition on slot {slot.name!r} constrains nothing") + return None + return violations + + def _presence_violation(self, subject: str, slot: SlotDefinition, var: str, presence: PresenceEnum) -> str | None: + """The pattern matching *subject* when its *slot* violates *presence*, binding *var* to a value that must + be absent; ``None`` when *presence* is ``UNCOMMITTED``.""" + value = f"{subject} <{self._slot_iri(slot)}> {var} ." + if presence == _PRESENT: + return f"FILTER NOT EXISTS {{ {value} }}" + if presence == _ABSENT: + return value + return None + + def _condition_violations( + self, + site: _RuleSite, + slot: SlotDefinition, + condition: SlotDefinition, + subject: str, + var: str, + default: PresenceEnum, + role: str, + ) -> tuple[PresenceEnum, list[str]] | None: + """The presence the *role* *condition* on *slot* of *subject* requires, and its other violations. + + A value bound to *var* violates the value operators or the + ``range_expression`` (:meth:`_value_violations`); ``has_member`` is + violated when the slot has values and none satisfies the member + condition. + """ + operators = self._set_operator_fields(condition) + if not operators <= self._CONDITION_OPERATORS: + self._skip_rule(site, self._unsupported(slot, operators, self._CONDITION_OPERATORS, role)) + return None + violations = self._value_violations(site, slot, condition, subject, var) + if violations is None: + return None + member_condition = condition.has_member + if member_condition is not None: + member_operators = self._set_operator_fields(member_condition) + if not member_operators or not member_operators <= self._VALUE_OPERATORS: + self._skip_rule(site, self._unsupported(slot, member_operators, self._VALUE_OPERATORS, "has_member")) + return None + member = f"{var}_member" + member_violations = self._value_violations(site, slot, member_condition, subject, member) + if member_violations is None: + return None + iri = self._slot_iri(slot) + satisfied = "".join(f" FILTER NOT EXISTS {{ {violation} }}" for violation in member_violations) + violations.append( + f"FILTER EXISTS {{ {subject} <{iri}> {var}_any . }} " + f"FILTER NOT EXISTS {{ {subject} <{iri}> {member} .{satisfied} }}" + ) + return self._presence(condition, default), violations + + def _value_violations( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, subject: str, var: str + ) -> list[str] | None: + """The patterns matching a value *var* of *slot* of *subject* that violates a value operator of *condition*. + + The value operators are tested on the value itself, a + ``range_expression`` on the value's own slots + (:meth:`_nested_violations`). + """ + value = f"{subject} <{self._slot_iri(slot)}> {var} ." + violations: list[str] = [] + if condition.range_expression is not None: + nested = self._nested_violations(site, slot, condition.range_expression, var) + if nested is None: + return None + violations.extend(f"{value} {violation}" for violation in nested) + test = self._value_test(site, slot, condition, var) + if test is None: + return None + if test: + violations.append(f"{value} FILTER ( !( {test} ) )") + return violations + + def _nested_violations( + self, site: _RuleSite, slot: SlotDefinition, expression: AnonymousClassExpression, node: str + ) -> list[str] | None: + """The patterns by which an object *node*, a value of *slot*, violates the class expression *expression*. + + *expression* must consist of slot conditions on the slot's range class, + resolved in that class's induced context. Each one holds for an absent + inner slot unless it requires the slot (:meth:`_presence`), as a slot + constraint applies to the values present and as the JSON Schema + generator reads a nested expression. On a reference that is not + inlined, the conditions apply to the referenced node's triples in the + data graph. + """ + sv = self.schemaview + operators = self._set_operator_fields(expression) + if operators != {"slot_conditions"}: + self._skip_rule( + site, self._unsupported(slot, operators, frozenset({"slot_conditions"}), "range_expression") + ) + return None + range_name = self._slot_range(slot) + if range_name not in sv.all_classes(): + self._skip_rule(site, f"its range_expression is on slot {slot.name!r}, whose range is not a class") + return None + range_class = sv.get_class(range_name) + violations: list[str] = [] + for index, (inner_name, condition) in enumerate(expression.slot_conditions.items()): + inner = self._rule_condition_slot(site, inner_name, range_class) + if inner is None: + return None + var = f"{node}_{index}" + checked = self._condition_violations(site, inner, condition, node, var, _UNCOMMITTED, "inner condition") + if checked is None: + return None + presence, inner_violations = checked + presence_violation = self._presence_violation(node, inner, var, presence) + if presence_violation: + violations.append(presence_violation) + violations.extend(inner_violations) + return violations + + def _value_test(self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, var: str) -> str | None: + """The SPARQL test that a value *var* of *slot* satisfies the value operators of *condition*. + + Returns ``""`` when *condition* sets no value operator, and ``None`` + (rule skipped) when a value cannot be translated. ``equals_string`` + and ``equals_string_in`` compare as :meth:`_value_terms` renders them. + ``pattern`` is matched against the string form of the value with + ``REGEX``, as ``sh:pattern`` is (`SHACL §4.4.2 + `_). + ``equals_number`` and the inclusive bounds ``minimum_value`` / + ``maximum_value`` (metamodel) are compared numerically, so the slot's + datatype must be one SPARQL compares as a number + (:data:`_SPARQL_NUMERIC_DATATYPES`). ``any_of``, ``all_of``, + ``exactly_one_of`` and ``none_of`` combine the tests of their members, + which may use only the operators of :data:`_TEST_OPERATORS`; a value is + a single term, so each member test is true or false and the operators + are the boolean ones of ``05validation.md``. Comparing a + value of another type with a number is a type error (`SPARQL 1.1 §17.3 + `_), which some + engines resolve as an ordering anyway, so `isNumeric + `_ guards the + comparison; an engine that takes an ill-typed literal such as + ``"abc"^^xsd:integer`` for a number still compares it. Every part is + false, rather than an error, for a value it cannot compare (see + :meth:`_sparql_is_one_of`). + """ + parts: list[str] = [] + for values in ( + [condition.equals_string] if condition.equals_string is not None else None, + list(condition.equals_string_in) if condition.equals_string_in else None, + ): + if values is None: + continue + terms = self._value_terms(site, slot, values) + if terms is None: + return None + parts.append(self._sparql_is_one_of(var, terms)) + if condition.pattern is not None: + pattern = self._sparql_string_literal(condition.pattern) + parts.append(f"COALESCE( REGEX( STR( {var} ), {pattern} ), false )") + bounds = { + name: (bound, operator) + for name, bound, operator in ( + ("equals_number", condition.equals_number, "="), + ("minimum_value", condition.minimum_value, ">="), + ("maximum_value", condition.maximum_value, "<="), + ) + if bound is not None + } + r = self._slot_range(slot) + if bounds and self._type_uri(r) not in self._SPARQL_NUMERIC_DATATYPES: + self._skip_rule( + site, + f"its {' and '.join(bounds)} on slot {slot.name!r} {'need' if len(bounds) > 1 else 'needs'} a type " + "with a numeric datatype, and " + (f"the slot's range is {r!r}" if r else "the slot has no range"), + ) + return None + comparisons: list[str] = [] + for bound, operator in bounds.values(): + number = self._sparql_number(bound) + if number is None: + self._skip_rule(site, f"its bound {bound!r} on slot {slot.name!r} is not a finite number") + return None + comparisons.append(f"{var} {operator} {number}") + if comparisons: + parts.append(f"COALESCE( {' && '.join([f'isNumeric( {var} )', *comparisons])}, false )") + for operator in sorted(self._BOOLEAN_OPERATORS): + members = getattr(condition, operator) or [] + if not members: + continue + tests: list[str] = [] + for member in members: + member_operators = self._set_operator_fields(member) + if not member_operators or not member_operators <= self._TEST_OPERATORS: + self._skip_rule( + site, self._unsupported(slot, member_operators, self._TEST_OPERATORS, f"{operator} member") + ) + return None + test = self._value_test(site, slot, member, var) + if test is None: + return None + tests.append(f"( {test} )") + parts.append(self._combine_tests(operator, tests)) + return " && ".join(parts) + + @staticmethod + def _combine_tests(operator: str, tests: list[str]) -> str: + """The SPARQL expression combining the member *tests* of the boolean *operator*. + + ``exactly_one_of`` counts the members that hold (``05validation.md``: + one member true and all others false). + """ + if operator == "any_of": + return f"( {' || '.join(tests)} )" + if operator == "all_of": + return f"( {' && '.join(tests)} )" + if operator == "none_of": + return f"!( {' || '.join(tests)} )" + return f"( ( {' + '.join(f'IF( {test}, 1, 0 )' for test in tests)} ) = 1 )" + + @staticmethod + def _sparql_number(value: object) -> str | None: + """Render a ``minimum_value`` / ``maximum_value`` bound as a SPARQL numeric literal, or ``None``. + + The metamodel range of both is ``Anything``, so strings, dates, + booleans and ``.nan`` / ``.inf`` reach the generator unchanged. + Interpolated raw, ``"abc"`` makes the query unparsable and a date such + as ``2020-01-01`` parses as arithmetic; only ``int`` and finite + ``float`` values are rendered, and their ``str`` is a plain numeric + token that SPARQL compares with numeric type promotion. + """ + if isinstance(value, bool) or not isinstance(value, int | float): + return None + if isinstance(value, float) and not math.isfinite(value): + return None + return str(value) + + @classmethod + def _unsupported(cls, slot: SlotDefinition, operators: set[str], supported: frozenset[str], role: str) -> str: + """The reason a condition with *operators* in *role* on *slot* is not translated.""" + used = ", ".join(sorted(operators)) or "no operator" + return ( + f"{cls._NO_PATTERN}; its {role} on slot {slot.name!r} uses {used}, where the compositional " + f"translation supports only {', '.join(sorted(supported))}" + ) + def _rule_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: """Resolve a rule condition's slot key to the slot it names, or ``None`` when no such slot exists. @@ -743,15 +1164,20 @@ def _rule_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | N return sv.induced_slot(canonical, cls.name) return sv.get_slot(slot_name) - def _rule_condition_slot(self, site: _RuleSite, slot_name: str) -> SlotDefinition | None: + def _rule_condition_slot( + self, site: _RuleSite, slot_name: str, cls: ClassDefinition | None = None + ) -> SlotDefinition | None: """The slot a condition of the rule at *site* names, or ``None`` (rule skipped) when it cannot be queried. + The slot is resolved in the context of ``site.cls``, or of *cls* for an + inner condition on the range class of a nested expression. + An unknown name would make the query use a predicate no shape or data uses. An identifier slot is the node's IRI, not a property arc (the main slot loop does not require it either), so a condition on it can never match. """ - slot = self._rule_slot(site.cls, slot_name) + slot = self._rule_slot(cls if cls is not None else site.cls, slot_name) if slot is None: self._skip_rule(site, f"its condition names {slot_name!r}, which is not a slot") return None diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index fd900bf938..5a67a70568 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -2163,8 +2163,6 @@ def test_preconditions(framework, s1, s2, is_valid): :param is_valid: whether the data is valid :return: """ - if framework == SHACL: - pytest.skip("shaclgen does not support rules yet") if framework == PANDERA_POLARS_CLASS: pytest.skip("panderagen does not support rules yet") @@ -2220,8 +2218,8 @@ def test_preconditions(framework, s1, s2, is_valid): ], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA, OWL]: - # only JSON Schema and OWL supports rules + if framework not in [JSON_SCHEMA, OWL, SHACL]: + # only JSON Schema, OWL and SHACL support rules expected_behavior = ValidationBehavior.INCOMPLETE check_data( schema, @@ -2379,8 +2377,6 @@ def test_preconditions_combos( :param is_valid: :return: """ - if framework == SHACL: - pytest.skip("shaclgen does not support rules yet") rule = {} if preconditions: rule["preconditions"] = { @@ -2412,8 +2408,8 @@ def test_preconditions_combos( ], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA, OWL]: - # only JSON Schema and OWL supports rules + if framework not in [JSON_SCHEMA, OWL, SHACL]: + # only JSON Schema, OWL and SHACL support rules expected_behavior = ValidationBehavior.INCOMPLETE if framework == OWL and schema_name == "Disjunctive": # TODO: investigate why the OWLAPI does not correctly parse the resulting structure @@ -2632,8 +2628,6 @@ def test_value_presence_in_rules(framework, multivalued, data_name, instance, is :param is_valid: whether the data is valid or not :return: """ - if framework == SHACL: - pytest.skip("shaclgen does not support boolean expressions yet") if framework == PANDERA_POLARS_CLASS: pytest.skip("panderagen does not support boolean expressions yet") classes = { @@ -2690,7 +2684,7 @@ def test_value_presence_in_rules(framework, multivalued, data_name, instance, is core_elements=["preconditions", "postconditions", "slot_conditions", "value_presence"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework != JSON_SCHEMA: + if framework not in [JSON_SCHEMA, SHACL]: if not is_valid: expected_behavior = ValidationBehavior.INCOMPLETE if multivalued: diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index 1982600f59..f3175d74ac 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -3535,17 +3535,24 @@ def test_rule_equals_string_agrees_with_json_schema( @pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) @pytest.mark.parametrize("target_class", ["Thing", "Sub"]) @pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize( + "guard_extra", [None, {"required": True}], ids=["presence-implies-value", "composed-equivalent"] +) def test_rule_inheritance_and_open_world_agree_with_json_schema( - target_range, value, other, instance, valid_closed_world, valid_open_world, target_class, open_world + target_range, value, other, instance, valid_closed_world, valid_open_world, target_class, open_world, guard_extra ): """A rule binds the instances of subclasses of its class, and ``open_world`` lets the postcondition be omitted, in SHACL as in JSON Schema. The metamodel defines ``open_world`` as "the postconditions may be omitted - in instance data", so an absent target satisfies an open-world rule. + in instance data", so an absent target satisfies an open-world rule. A + guard that also states ``required: true`` means the same and is composed + rather than matched by the named pattern, with the same verdicts. """ obj = {key: text.format(value=value, other=other) for key, text in instance.items()} - schema = _rule_target_schema(target_range, value, open_world=open_world) + schema = _rule_target_schema(target_range, value, open_world=open_world, guard_extra=guard_extra) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR[target_class]) + assert ("?pre0" in query) == (guard_extra is not None), query # the composed form filters on ?pre0 valid = valid_open_world if open_world else valid_closed_world assert _json_schema_and_shacl_verdicts(schema, target_class, obj) == (valid, valid) @@ -4297,6 +4304,8 @@ def _rule(pre: dict, post: dict, **rule_fields) -> dict: _GUARD_PRESENT = {"guard": {"value_presence": "PRESENT"}} +# a postcondition no translation supports: equals_expression compares with another slot +_TARGET_EQUALS_OTHER = {"target": {"equals_expression": "{other}"}} def _assert_skipped(caplog, schema: str, reason: str) -> None: @@ -4408,9 +4417,7 @@ def test_rule_problem_reported_once_for_inheriting_classes(caplog): with elseconditions is not also reported as partially emitted.""" schema = json.loads( - _single_rule_schema( - _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, elseconditions={"slot_conditions": {}}) - ) + _single_rule_schema(_rule(_GUARD_PRESENT, _TARGET_EQUALS_OTHER, elseconditions={"slot_conditions": {}})) ) for name in ("C1", "C2", "C3"): schema["classes"][name] = {"is_a": "Thing", "class_uri": f"ex:{name}"} @@ -4425,7 +4432,7 @@ def test_rule_problem_reported_once_for_inheriting_classes(caplog): def test_rule_problem_names_every_affected_shape_whatever_the_class_order(caplog): """A problem with an inherited rule names every class shape it affects, so the report does not depend on the order in which classes are declared.""" - schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}))) + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, _TARGET_EQUALS_OTHER))) schema["classes"] = {"Sub": {"is_a": "Thing", "class_uri": "ex:Sub"}, **schema["classes"]} with caplog.at_level(logging.WARNING): _parse_shacl(json.dumps(schema)) @@ -4438,12 +4445,12 @@ def test_rule_problems_reported_per_rule_and_per_problem(caplog): """Problems are kept apart by rule and by kind: two rules with the same problem, and one rule with two problems, each produce their own warning. Each warning names the rule by its position and description.""" - schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}))) + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, _TARGET_EQUALS_OTHER))) schema["enums"] = {"Color": {"permissible_values": {"Red": {}, "Blue": {}}}} schema["classes"]["Thing"]["attributes"]["color"] = {"range": "Color"} schema["classes"]["Thing"]["rules"] = [ - _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, description="First"), - _rule(_GUARD_PRESENT, {"target": {"pattern": "^y"}}, description="Second"), + _rule(_GUARD_PRESENT, _TARGET_EQUALS_OTHER, description="First"), + _rule(_GUARD_PRESENT, {"target": {"equals_expression": "{guard}"}}, description="Second"), _rule( _GUARD_PRESENT, {"color": {"equals_string": "Purple"}}, @@ -4590,36 +4597,75 @@ def test_rule_equals_string_special_chars_escaped(): @pytest.mark.parametrize( - "extra", + "extra,reason", [ - pytest.param({"pattern": "^x"}, id="pattern"), - pytest.param({"minimum_value": 0}, id="minimum_value"), - pytest.param({"maximum_value": 5}, id="maximum_value"), - pytest.param({"required": True}, id="required"), - pytest.param({"recommended": True}, id="recommended"), - pytest.param({"minimum_cardinality": 1}, id="minimum_cardinality"), - pytest.param({"exact_cardinality": 1}, id="exact_cardinality"), - pytest.param({"equals_number": 3}, id="equals_number"), - pytest.param({"has_member": {"equals_string": "x"}}, id="has_member"), - pytest.param({"any_of": [{"equals_string": "x"}]}, id="slot-any_of"), + pytest.param({"equals_expression": "{other}"}, "equals_expression", id="equals_expression"), + pytest.param({"minimum_value": 0}, "needs a type with a numeric datatype", id="minimum_value"), + pytest.param({"maximum_value": 5}, "needs a type with a numeric datatype", id="maximum_value"), + pytest.param({"equals_number": 3}, "needs a type with a numeric datatype", id="equals_number-on-string"), + pytest.param({"recommended": True}, "recommended", id="recommended"), + pytest.param({"minimum_cardinality": 1}, "minimum_cardinality", id="minimum_cardinality"), + pytest.param({"exact_cardinality": 1}, "exact_cardinality", id="exact_cardinality"), + pytest.param({"any_of": [{"minimum_cardinality": 1}]}, "any_of member", id="slot-any_of-member"), ], ) @pytest.mark.parametrize("condition", ["guard", "target"]) -def test_rule_extra_condition_operator_skipped(caplog, condition, extra): - """A guard or target condition that adds any operator to the ones a pattern - translates makes the rule untranslatable.""" +def test_rule_extra_condition_operator_skipped(caplog, condition, extra, reason): + """A guard or target condition that adds an operator no translation + supports, or a bound on a string slot, makes the rule untranslatable: + dropping it would widen or weaken the rule.""" guard = {"value_presence": "PRESENT", **(extra if condition == "guard" else {})} target = {"equals_string": "x", **(extra if condition == "target" else {})} schema = _single_rule_schema(_rule({"guard": guard}, {"target": target})) - _assert_skipped(caplog, schema, "match none of the translated patterns") + _assert_skipped(caplog, schema, reason) -@pytest.mark.parametrize("presence", ["ABSENT", "UNCOMMITTED"]) -def test_rule_guard_presence_other_than_present_skipped(caplog, presence): - """Only ``value_presence: PRESENT`` is a guard: reading ``ABSENT`` as a - guard would invert the trigger.""" - schema = _single_rule_schema(_rule({"guard": {"value_presence": presence}}, {"target": {"equals_string": "x"}})) - _assert_skipped(caplog, schema, "match none of the translated patterns") +@pytest.mark.parametrize( + "guard_extra,target_extra", + [ + pytest.param({"required": True}, None, id="guard-required"), + pytest.param(None, {"required": True}, id="target-required"), + ], +) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) +def test_rule_extra_operator_translated_exactly( + guard_extra, target_extra, open_world, instance, valid_closed_world, valid_open_world +): + """An extra operator the composed translation supports is translated, not + dropped: a redundant ``required: true`` on the guard changes nothing, and on + the target it requires the target even in an open world.""" + obj = {key: text.format(value="x", other="y") for key, text in instance.items()} + schema = _rule_target_schema( + "string", "x", open_world=open_world, guard_extra=guard_extra, target_extra=target_extra + ) + target_required = bool(target_extra and target_extra.get("required")) + valid = valid_closed_world if target_required or not open_world else valid_open_world + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + assert _json_schema_and_shacl_verdicts(schema, "Thing", obj) == (valid, valid) + + +@pytest.mark.parametrize( + "presence,instance,valid", + [ + pytest.param("ABSENT", {"guard": "g", "target": "y"}, True, id="absent-guard-present"), + pytest.param("ABSENT", {"target": "y"}, False, id="absent-other"), + pytest.param("ABSENT", {"target": "x"}, True, id="absent-allowed"), + pytest.param("ABSENT", {}, False, id="absent-target-absent"), + pytest.param("UNCOMMITTED", {"guard": "g", "target": "y"}, False, id="uncommitted-other"), + pytest.param("UNCOMMITTED", {"target": "x"}, True, id="uncommitted-allowed"), + pytest.param("UNCOMMITTED", {}, False, id="uncommitted-target-absent"), + ], +) +def test_rule_guard_presence_other_than_present_composed(presence, instance, valid): + """A guard with ``value_presence: ABSENT`` triggers the rule when the guard + is absent, one with ``UNCOMMITTED`` always; the named pattern, which reads + ``PRESENT``, does not match them, and the composed translation does, as in + JSON Schema.""" + schema = _rule_target_schema("string", "x", guard_extra={"value_presence": presence}) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert "?guard" not in query, query # composed, not the named pattern + assert _json_schema_and_shacl_verdicts(schema, "Thing", instance) == (valid, valid) @pytest.mark.parametrize("operator", ["any_of", "all_of", "exactly_one_of", "none_of"]) @@ -4632,11 +4678,21 @@ def test_rule_expression_level_operator_skipped(caplog, side, operator): _assert_skipped(caplog, _single_rule_schema(rule), f"its {side} use {operator}") -def test_rule_post_with_both_equals_forms_skipped(caplog): - """equals_string and equals_string_in set together is ambiguous — skip, - do not let one form silently win.""" - schema = _single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "a", "equals_string_in": ["b"]}})) - _assert_skipped(caplog, schema, "match none of the translated patterns") +@pytest.mark.parametrize( + "allowed,target,valid", + [ + pytest.param(["x", "y"], "x", True, id="in-both"), + pytest.param(["x", "y"], "y", False, id="in-one"), + pytest.param(["y"], "x", False, id="contradictory"), + pytest.param(["y"], "y", False, id="contradictory-other"), + ], +) +def test_rule_post_with_both_equals_forms_is_a_conjunction(allowed, target, valid): + """equals_string and equals_string_in set together must both hold, as in + JSON Schema (``const`` and ``enum``); neither form silently wins.""" + schema = _rule_target_schema("string", "x", target_extra={"equals_string_in": allowed}) + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + assert _json_schema_and_shacl_verdicts(schema, "Thing", {"guard": "g", "target": target}) == (valid, valid) def test_rule_exclusive_value_extra_operator_skipped(caplog): @@ -4707,3 +4763,1084 @@ def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): ) _, focus_nodes = _validate_rules(_STRING_TRUE_SCHEMA_YAML, data) assert focus_nodes == ({URIRef("https://example.org/string-true/x")} if violates else set()) + + +# =========================================================================== +# Compositional fallback +# +# A rule no named pattern matches is composed from its operators. Whether a +# condition requires its slot follows the JSON Schema generator: value_presence, +# then required, then a default (preconditions do, postconditions unless +# open_world, inner conditions of a nested expression do not). Each value of a +# slot must satisfy the condition: a slot constraint applies to all members of +# a collection (05validation.md), as the JSON Schema generator's `items` reads +# it; has_member requires some value to satisfy its condition. +# =========================================================================== + +EX_COMP = rdflib.Namespace("https://example.org/compose/") +_COMP_PREFIXES = "@prefix ex: .\n@prefix xsd: .\n" + + +def _compose_schema( + rule: dict, + *, + part_usage: dict | None = None, + thing_usage: dict | None = None, + slots: list[str] | None = None, + subclass: bool = False, +) -> str: + """A schema whose class ``Thing`` has the slots used by the compositional tests and the one *rule*. + + ``Part`` is the inlined class of ``part`` / ``parts`` and of its own + ``sub``; ``SpecialPart`` narrows its ``kind``. ``site`` references a + ``Site`` by its identifier. *part_usage* and + *thing_usage* add ``slot_usage``, *slots* adds schema slots to ``Thing``, + and *subclass* adds ``SubThing``, which inherits the rule. + """ + schema = { + "id": "https://example.org/compose", + "name": "compose", + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": str(EX_COMP), "xsd": str(XSD)}, + "imports": ["linkml:types"], + "default_prefix": "ex", + "default_range": "string", + "enums": { + "Kind": {"permissible_values": {"Plain": {}, "Special": {"meaning": "ex:Special"}}}, + "SpecialKind": {"permissible_values": {"Special": {"meaning": "ex:VerySpecial"}}}, + }, + "slots": {"kind": {"range": "Kind"}}, + "classes": { + "Part": { + "class_uri": "ex:Part", + "slots": ["kind"], + "attributes": { + "depth": {"range": "integer"}, + "label": {}, + "marks": {"range": "integer", "multivalued": True}, + "sub": {"range": "Part", "inlined": True}, + }, + "slot_usage": part_usage or {}, + }, + "SpecialPart": { + "is_a": "Part", + "class_uri": "ex:SpecialPart", + "slot_usage": {"kind": {"range": "SpecialKind"}}, + }, + "Site": { + "class_uri": "ex:Site", + "attributes": {"id": {"identifier": True, "range": "uriorcurie"}, "depth": {"range": "integer"}}, + }, + "Thing": { + "class_uri": "ex:Thing", + "slots": slots or [], + "attributes": { + "mode": {}, + "flag": {"range": "boolean"}, + "level": {"range": "integer"}, + "levels": {"range": "integer", "multivalued": True}, + "note": {}, + "part": {"range": "Part", "inlined": True}, + "parts": {"range": "Part", "inlined": True, "multivalued": True, "inlined_as_list": True}, + "site": {"range": "Site"}, + }, + "slot_usage": thing_usage or {}, + "rules": [rule], + }, + }, + } + if subclass: + schema["classes"]["SubThing"] = {"is_a": "Thing", "class_uri": "ex:SubThing"} + return json.dumps(schema) + + +_REQUIRE_NOTE = {"note": {"required": True}} +_DEPTH_AT_MOST_ZERO = {"range_expression": {"slot_conditions": {"depth": {"maximum_value": 0}}}} +_IF_MODE_M = {"mode": {"equals_string": "m"}} +_LEVEL_10_TO_20 = {"level": {"minimum_value": 10, "maximum_value": 20}} + + +def _inner(**conditions: dict) -> dict: + """A ``range_expression`` with the slot *conditions* on the slot's class.""" + return {"range_expression": {"slot_conditions": conditions}} + + +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +@pytest.mark.parametrize( + "pre,post,instance,valid", + [ + # equals_string, and presence in the postcondition + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "m"}, False, id="equals-required"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "m", "note": "n"}, True, id="equals-met"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "x"}, True, id="equals-not-triggered"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {}, True, id="precondition-slot-absent"), + pytest.param(_IF_MODE_M, {"note": {"value_presence": "PRESENT"}}, {"mode": "m"}, False, id="presence-post"), + pytest.param(_IF_MODE_M, {"note": {}}, {"mode": "m"}, False, id="empty-post-required-by-default"), + pytest.param( + _IF_MODE_M, {"note": {"value_presence": "ABSENT"}}, {"mode": "m", "note": "n"}, False, id="absent-post" + ), + pytest.param(_IF_MODE_M, {"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), + # numeric bounds are inclusive + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 15}, False, id="bounds-inside"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 10}, False, id="bounds-at-minimum"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 20}, False, id="bounds-at-maximum"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 25}, True, id="bounds-above"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 5}, True, id="bounds-below"), + # presence in the precondition + pytest.param({"level": {"value_presence": "PRESENT"}}, _REQUIRE_NOTE, {"level": 1}, False, id="presence-pre"), + pytest.param({"mode": {"required": True}}, _REQUIRE_NOTE, {"mode": "m"}, False, id="required-pre"), + pytest.param({"mode": {"required": True}}, _REQUIRE_NOTE, {}, True, id="required-pre-absent"), + pytest.param({"mode": {}}, _REQUIRE_NOTE, {"mode": "x"}, False, id="empty-pre-requires-presence"), + pytest.param({"mode": {}}, _REQUIRE_NOTE, {}, True, id="empty-pre-absent"), + pytest.param({"mode": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE, {}, False, id="absent-pre"), + pytest.param( + {"mode": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE, {"mode": "m"}, True, id="absent-pre-present" + ), + pytest.param( + {"mode": {"required": False, "equals_string": "m"}}, _REQUIRE_NOTE, {}, False, id="optional-pre-absent" + ), + pytest.param( + {"mode": {"required": False, "equals_string": "m"}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="optional-pre" + ), + pytest.param( + {"mode": {"value_presence": "UNCOMMITTED", "equals_string": "m"}}, + _REQUIRE_NOTE, + {}, + False, + id="uncommitted-pre-absent", + ), + pytest.param( + {"mode": {"value_presence": "UNCOMMITTED", "required": True, "equals_string": "m"}}, + _REQUIRE_NOTE, + {}, + False, + id="value-presence-overrides-required", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {}, + True, + id="presence-bound-absent", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {"level": 1}, + True, + id="presence-bound-fails", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {"level": 5}, + False, + id="presence-bound-holds", + ), + # every value of a multivalued slot + pytest.param( + {"levels": {"minimum_value": 10}}, _REQUIRE_NOTE, {"levels": [12, 15]}, False, id="every-value-holds" + ), + pytest.param({"levels": {"minimum_value": 10}}, _REQUIRE_NOTE, {"levels": [12, 3]}, True, id="one-value-fails"), + # a nested range_expression, whose inner conditions hold for an absent inner slot + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {"depth": -1}}, False, id="nested-holds"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {"depth": 5}}, True, id="nested-fails"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {}}, False, id="nested-inner-absent"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {}, True, id="nested-container-absent"), + pytest.param( + {"part": {"required": False, **_DEPTH_AT_MOST_ZERO}}, _REQUIRE_NOTE, {}, False, id="optional-container" + ), + pytest.param( + {"part": _inner(depth={"maximum_value": 0, "required": True})}, + _REQUIRE_NOTE, + {"part": {}}, + True, + id="nested-inner-required-absent", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "PRESENT"})}, + _REQUIRE_NOTE, + {"part": {}}, + True, + id="nested-inner-present-absent", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "PRESENT"})}, + _REQUIRE_NOTE, + {"part": {"depth": 1}}, + False, + id="nested-inner-present", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "ABSENT"})}, + _REQUIRE_NOTE, + {"part": {}}, + False, + id="nested-inner-absent-holds", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "ABSENT"})}, + _REQUIRE_NOTE, + {"part": {"depth": 1}}, + True, + id="nested-inner-absent-fails", + ), + pytest.param( + {"part": _inner(depth={"minimum_value": -5, "maximum_value": 0})}, + _REQUIRE_NOTE, + {"part": {"depth": 3}}, + True, + id="nested-bounds-above", + ), + pytest.param( + {"part": _inner(sub=_DEPTH_AT_MOST_ZERO)}, + _REQUIRE_NOTE, + {"part": {"sub": {"depth": -1}}}, + False, + id="two-hops", + ), + pytest.param( + {"part": _inner(sub=_DEPTH_AT_MOST_ZERO)}, + _REQUIRE_NOTE, + {"part": {"sub": {"depth": 5}}}, + True, + id="two-hops-fails", + ), + pytest.param( + {"parts": _DEPTH_AT_MOST_ZERO}, + _REQUIRE_NOTE, + {"parts": [{"depth": -1}, {"depth": -2}]}, + False, + id="every-member", + ), + pytest.param( + {"parts": _DEPTH_AT_MOST_ZERO}, + _REQUIRE_NOTE, + {"parts": [{"depth": -1}, {"depth": 5}]}, + True, + id="one-member-fails", + ), + # value operators in a postcondition apply to every value of the slot, which is required + pytest.param( + _IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m", "note": "n"}, True, id="post-equals-met" + ), + pytest.param( + _IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="post-equals-other" + ), + pytest.param(_IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m"}, False, id="post-equals-absent"), + pytest.param( + _IF_MODE_M, {"note": {"equals_string_in": ["n", "o"]}}, {"mode": "m", "note": "o"}, True, id="post-in-met" + ), + pytest.param( + _IF_MODE_M, + {"note": {"equals_string_in": ["n", "o"]}}, + {"mode": "m", "note": "x"}, + False, + id="post-in-other", + ), + pytest.param(_IF_MODE_M, {"level": {"minimum_value": 3}}, {"mode": "m", "level": 3}, True, id="post-bound-met"), + pytest.param( + _IF_MODE_M, {"level": {"minimum_value": 3}}, {"mode": "m", "level": 2}, False, id="post-bound-fails" + ), + pytest.param( + _IF_MODE_M, {"levels": {"maximum_value": 5}}, {"mode": "m", "levels": [1, 5]}, True, id="post-every-value" + ), + pytest.param( + _IF_MODE_M, {"levels": {"maximum_value": 5}}, {"mode": "m", "levels": [1, 9]}, False, id="post-one-fails" + ), + pytest.param( + _IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {"depth": 0}}, True, id="post-nested-met" + ), + pytest.param( + _IF_MODE_M, + {"part": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "part": {"depth": 1}}, + False, + id="post-nested-fails", + ), + pytest.param(_IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, False, id="post-nested-absent"), + pytest.param( + _IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {}}, True, id="post-nested-inner-absent" + ), + pytest.param( + _IF_MODE_M, + {"parts": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "parts": [{"depth": 0}, {"depth": -1}]}, + True, + id="post-nested-every-member", + ), + pytest.param( + _IF_MODE_M, + {"parts": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "parts": [{"depth": 0}, {"depth": 5}]}, + False, + id="post-nested-one-member-fails", + ), + # equals_string_in in a precondition and an inner condition + pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "n"}, False, id="pre-in"), + pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="pre-in-other"), + pytest.param( + {"part": _inner(label={"equals_string_in": ["a", "b"]})}, + _REQUIRE_NOTE, + {"part": {"label": "b"}}, + False, + id="inner-in", + ), + # several preconditions are a conjunction + pytest.param( + {**_IF_MODE_M, "level": {"minimum_value": 10}}, + _REQUIRE_NOTE, + {"mode": "m", "level": 12}, + False, + id="two-preconditions", + ), + pytest.param( + {**_IF_MODE_M, "level": {"minimum_value": 10}}, + _REQUIRE_NOTE, + {"mode": "m", "level": 3}, + True, + id="two-preconditions-one-fails", + ), + ], +) +def test_compose_agrees_with_json_schema(pre, post, instance, valid, target_class): + """A composed rule decides each instance as the JSON Schema generator's + if/then does, in the class declaring it and in a subclass.""" + schema = _compose_schema(_rule(pre, post), subclass=True) + assert len(_sparql_queries(_parse_shacl(schema), EX_COMP[target_class])) == 1 + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +@pytest.mark.parametrize( + "post,instance,valid", + [ + pytest.param(_REQUIRE_NOTE, {"mode": "m"}, False, id="required-absent"), + pytest.param(_REQUIRE_NOTE, {"mode": "m", "note": "n"}, True, id="required-met"), + pytest.param({"note": {"value_presence": "PRESENT"}}, {"mode": "m"}, False, id="present-absent"), + pytest.param({"note": {"value_presence": "PRESENT"}}, {"mode": "m", "note": "n"}, True, id="present-met"), + pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m", "note": "n"}, False, id="absent-present"), + pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), + pytest.param({"note": {"equals_string": "n"}}, {"mode": "m"}, True, id="value-omitted"), + pytest.param({"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="value-other"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, True, id="nested-omitted"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {}}, True, id="nested-inner-absent"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {"depth": 1}}, False, id="nested-other"), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_open_world(post, instance, valid, target_class): + """With ``open_world`` a postcondition slot may be omitted unless the + postcondition states its presence, as in JSON Schema; a value present must + still satisfy it.""" + schema = _compose_schema(_rule(_IF_MODE_M, post, open_world=True), subclass=True) + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +_TWO_POSTCONDITIONS = {"note": {"equals_string": "ok"}, "level": {"minimum_value": 1}} + + +@pytest.mark.parametrize( + "open_world,instance,valid", + [ + pytest.param(False, {"mode": "m", "note": "ok", "level": 2}, True, id="both-met"), + pytest.param(False, {"mode": "m", "note": "bad", "level": 2}, False, id="first-fails"), + pytest.param(False, {"mode": "m", "note": "ok", "level": 0}, False, id="second-fails"), + pytest.param(False, {"mode": "m", "note": "bad", "level": 0}, False, id="both-fail"), + pytest.param(False, {"mode": "m", "note": "ok"}, False, id="second-absent"), + pytest.param(False, {"mode": "x", "note": "bad", "level": 0}, True, id="precondition-not-met"), + pytest.param(True, {"mode": "m", "note": "ok"}, True, id="open-world-second-absent"), + pytest.param(True, {"mode": "m", "note": "ok", "level": 0}, False, id="open-world-second-fails"), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_several_postconditions_agree_with_json_schema(open_world, instance, valid, target_class): + """A rule whose postconditions constrain several slots is violated when any + of them is: it becomes one constraint per postcondition slot and decides + each instance as the JSON Schema generator's ``then`` does.""" + schema = _compose_schema(_rule(_IF_MODE_M, _TWO_POSTCONDITIONS, open_world=open_world), subclass=True) + assert len(_sparql_queries(_parse_shacl(schema), EX_COMP[target_class])) == 2 + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +def test_compose_several_postconditions_report_each_slot(): + """Each violated postcondition slot is reported with its own ``sh:resultPath`` and value.""" + schema = _compose_schema(_rule(_IF_MODE_M, _TWO_POSTCONDITIONS)) + data = _COMP_PREFIXES + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "bad" ; ex:level 0 .' + _, results = _rule_results(schema, data) + assert sorted(results) == sorted( + [(EX_COMP.x, EX_COMP.note, Literal("bad")), (EX_COMP.x, EX_COMP.level, Literal(0))] + ) + + +_SPECIAL_KIND = {"kind": {"equals_string": "Special"}} + + +@pytest.mark.parametrize( + "member,post_presence,open_world,members,violates", + [ + pytest.param(_SPECIAL_KIND, {}, False, "ex:p1 . ex:p1 ex:kind ex:Special", False, id="matching-member"), + pytest.param(_SPECIAL_KIND, {}, False, 'ex:p1 . ex:p1 ex:kind "Plain"', True, id="no-matching-member"), + pytest.param( + _SPECIAL_KIND, + {}, + False, + 'ex:p1, ex:p2 . ex:p1 ex:kind "Plain" . ex:p2 ex:kind ex:Special', + False, + id="one-matching", + ), + pytest.param(_SPECIAL_KIND, {}, False, 'ex:p1 . ex:p1 ex:label "l"', False, id="member-without-kind"), + pytest.param( + {"kind": {"equals_string": "Special", "required": True}}, + {}, + False, + 'ex:p1 . ex:p1 ex:label "l"', + True, + id="member-without-required-kind", + ), + pytest.param( + {"kind": {"value_presence": "ABSENT"}}, + {}, + False, + "ex:p1 . ex:p1 ex:kind ex:Special", + True, + id="inner-absent", + ), + pytest.param( + {"kind": {"value_presence": "ABSENT"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:label "l"', + False, + id="inner-absent-met", + ), + pytest.param( + {**_SPECIAL_KIND, "label": {"equals_string": "l"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:kind ex:Special ; ex:label "x"', + True, + id="two-inner-conditions-one-fails", + ), + pytest.param( + {**_SPECIAL_KIND, "label": {"equals_string": "l"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:kind ex:Special ; ex:label "l"', + False, + id="two-inner-conditions-hold", + ), + pytest.param(_SPECIAL_KIND, {}, False, None, True, id="no-members"), + pytest.param(_SPECIAL_KIND, {}, True, None, False, id="no-members-open-world"), + pytest.param(_SPECIAL_KIND, {"required": True}, True, None, True, id="no-members-open-world-required"), + pytest.param(_SPECIAL_KIND, {"required": False}, False, None, False, id="no-members-not-required"), + pytest.param( + _SPECIAL_KIND, + {"value_presence": "UNCOMMITTED", "required": True}, + False, + None, + False, + id="value-presence-overrides-required", + ), + pytest.param(_SPECIAL_KIND, {"value_presence": "ABSENT"}, False, None, False, id="absent-slot"), + pytest.param( + _SPECIAL_KIND, + {"value_presence": "ABSENT"}, + False, + "ex:p1 . ex:p1 ex:kind ex:Special", + True, + id="absent-slot-present", + ), + pytest.param( + {"sub": _DEPTH_AT_MOST_ZERO}, + {}, + False, + "ex:p1 . ex:p1 ex:sub ex:s1 . ex:s1 ex:depth 0", + False, + id="two-hops", + ), + pytest.param( + {"sub": _DEPTH_AT_MOST_ZERO}, + {}, + False, + "ex:p1 . ex:p1 ex:sub ex:s1 . ex:s1 ex:depth 5", + True, + id="two-hops-fails", + ), + pytest.param( + _SPECIAL_KIND, {}, True, 'ex:p1 . ex:p1 ex:kind "Plain"', True, id="no-matching-member-open-world" + ), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_has_member(member, post_presence, open_world, members, violates, target_class): + """``has_member`` is violated when no member satisfies the member condition. + + A member condition holds for an absent inner slot unless it requires the + slot. Whether ``parts`` must be present is decided as for any + postcondition (``open_world``, ``required``, ``value_presence``). The JSON + Schema generator drops ``has_member`` inside rule conditions, so the + verdicts are checked against explicit expectations. + """ + post = {"parts": {"has_member": _inner(**member), **post_presence}} + schema = _compose_schema(_rule({"mode": {"value_presence": "PRESENT"}}, post, open_world=open_world), subclass=True) + body = f'ex:x a ex:{target_class} ; ex:mode "m"' + ("" if members is None else f" ; ex:parts {members}") + _, focus_nodes = _validate_rules(schema, f"{_COMP_PREFIXES}{body} .") + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +_SOME_LEVEL_10 = {"levels": {"has_member": {"minimum_value": 10}}} +_SOME_LEVEL_10_ALL_20 = {"levels": {"has_member": {"minimum_value": 10}, "maximum_value": 20}} +_PART_WITH_SOME_MARK_10 = {"part": _inner(marks={"has_member": {"minimum_value": 10}})} + + +@pytest.mark.parametrize( + "pre,post,data,violates", + [ + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, " ; ex:levels 3, 12", False, id="post-some"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, " ; ex:levels 3, 4", True, id="post-none"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, "", True, id="post-absent"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, " ; ex:levels 3, 12", True, id="pre-some"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, " ; ex:levels 3, 4", False, id="pre-none"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, "", False, id="pre-absent"), + # with value operators on the same slot, which every value must satisfy + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 12, 15", False, id="post-some-and-all"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 12, 25", True, id="post-some-not-all"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 3, 4", True, id="post-all-not-some"), + # inside a nested condition, on the values of the inner slot + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, " ; ex:part ex:p1 . ex:p1 ex:marks 3, 12", True, id="inner-some" + ), + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, " ; ex:part ex:p1 . ex:p1 ex:marks 3, 4", False, id="inner-none" + ), + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, ' ; ex:part ex:p1 . ex:p1 ex:label "l"', True, id="inner-absent" + ), + pytest.param( + {**_PART_WITH_SOME_MARK_10, "levels": {"minimum_value": 0}}, + _REQUIRE_NOTE, + " ; ex:levels 1 ; ex:part ex:p1 . ex:p1 ex:marks 4 . ex:y a ex:Thing ; ex:part ex:p2 . ex:p2 ex:marks 12", + False, + id="inner-member-of-another-subject", + ), + ], +) +def test_compose_has_member_with_value_operators(pre, post, data, violates): + """``has_member`` holds when some value satisfies its value operators, in a + postcondition, a precondition and a nested condition alike, next to value + operators that every value must satisfy. An absent slot fails a + precondition, which requires its slot, violates a closed-world + postcondition, and satisfies a nested condition, which does not.""" + schema = _compose_schema(_rule(pre, post)) + _, focus_nodes = _validate_rules(schema, f'{_COMP_PREFIXES}ex:x a ex:Thing ; ex:mode "m"{data} .') + assert EX_COMP.x in focus_nodes if violates else EX_COMP.x not in focus_nodes + + +@pytest.mark.parametrize( + "rule,data,expected", + [ + pytest.param( + _rule(_IF_MODE_M, _REQUIRE_NOTE), + 'ex:x a ex:Thing ; ex:mode "m" .', + [(EX_COMP.x, EX_COMP.note, EX_COMP.x)], + id="required", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"value_presence": "ABSENT"}}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "n" .', + [(EX_COMP.x, EX_COMP.note, Literal("n"))], + id="absent", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"equals_string": "n"}}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "a", "n", "b" .', + [(EX_COMP.x, EX_COMP.note, Literal("a")), (EX_COMP.x, EX_COMP.note, Literal("b"))], + id="each-offending-value", + ), + pytest.param( + _rule(_IF_MODE_M, {"parts": _inner(label={"equals_string": "l"}, depth={"maximum_value": 0})}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:parts ex:p1 . ex:p1 ex:label "x" ; ex:depth 5 .', + [(EX_COMP.x, EX_COMP.parts, EX_COMP.p1)], + id="value-violating-twice-reported-once", + ), + pytest.param( + _rule(_IF_MODE_M, _SOME_LEVEL_10), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:levels 3, 4 .', + [(EX_COMP.x, EX_COMP.levels, EX_COMP.x)], + id="no-member-satisfies", + ), + ], +) +def test_compose_result_names_path_and_value(rule, data, expected): + """A composed result names the postcondition's property and the offending + value, once per value, or the focus node when a value is missing (SHACL + §5.3.2).""" + _, results = _rule_results(_compose_schema(rule), _COMP_PREFIXES + data) + assert sorted(results) == sorted(expected) + + +def test_compose_query_yields_one_solution_per_offending_value(): + """A value that violates two parts of the postcondition is one solution, + so a processor that maps every solution to a result (SHACL §5.3.2) reports + it once.""" + rule = _rule(_IF_MODE_M, {"parts": _inner(label={"equals_string": "l"}, depth={"maximum_value": 0})}) + (query,) = _sparql_queries(_parse_shacl(_compose_schema(rule)), EX_COMP.Thing) + data = rdflib.Graph().parse( + data=_COMP_PREFIXES + 'ex:x a ex:Thing ; ex:mode "m" ; ex:parts ex:p1 . ex:p1 ex:label "x" ; ex:depth 5 .', + format="turtle", + ) + solutions = [(row.path, row.value) for row in data.query(query, initBindings={"this": EX_COMP.x})] + assert solutions == [(EX_COMP.parts, EX_COMP.p1)] + + +@pytest.mark.parametrize( + "flag,violates", + [ + pytest.param(Literal("true", datatype=XSD.boolean, normalize=False), True, id="true"), + pytest.param(Literal("1", datatype=XSD.boolean, normalize=False), True, id="lexical-1"), + pytest.param(Literal("false", datatype=XSD.boolean, normalize=False), False, id="false"), + pytest.param(Literal("true"), False, id="string-true"), + ], +) +def test_compose_boolean_precondition_compared_by_value(flag, violates): + """``equals_string`` on a boolean precondition slot compares the boolean it denotes, as in the named patterns.""" + schema = _compose_schema(_rule({"flag": {"equals_string": "true"}}, _REQUIRE_NOTE)) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.flag, flag)) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "kind,violates", + [ + pytest.param("ex:Special", False, id="meaning-iri"), + pytest.param('"Special"', True, id="permissible-value-text"), + pytest.param('"Plain"', True, id="other-value"), + ], +) +def test_compose_nested_postcondition_compares_enum_meaning(kind, violates): + """In a nested postcondition, an enum value with a ``meaning`` is compared as + that IRI, resolved on the range class, as ``rdflib_dumper`` writes it. The + generated JSON-LD context leaves the values of this enum, which has a value + without a meaning, as strings, so the expectations are explicit.""" + rule = _rule(_IF_MODE_M, {"part": _inner(kind={"equals_string": "Special"})}) + data = f'{_COMP_PREFIXES}ex:x a ex:Thing ; ex:mode "m" ; ex:part ex:p . ex:p ex:kind {kind} .' + _, focus_nodes = _validate_rules(_compose_schema(rule), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +# The numeric datatypes of SPARQL 1.1 §17.1, . +_SPARQL_NUMERIC_DATATYPES = [ + *("integer", "decimal", "float", "double"), + *("nonPositiveInteger", "negativeInteger", "long", "int", "short", "byte"), + *("nonNegativeInteger", "unsignedLong", "unsignedInt", "unsignedShort", "unsignedByte", "positiveInteger"), +] + + +@pytest.mark.parametrize("datatype", _SPARQL_NUMERIC_DATATYPES) +def test_compose_bounds_on_every_sparql_numeric_datatype(datatype): + """Bounds are translated on a type with any numeric datatype of SPARQL 1.1 + §17.1 and compare its values numerically.""" + schema = json.loads(_compose_schema(_rule({"amount": {"minimum_value": -10, "maximum_value": 10}}, _REQUIRE_NOTE))) + base = datatype if datatype in ("decimal", "float", "double") else "integer" + schema["types"] = {"Amount": {"typeof": base, "uri": f"xsd:{datatype}"}} + schema["classes"]["Thing"]["attributes"]["amount"] = {"range": "Amount"} + sign = -1 if datatype in ("nonPositiveInteger", "negativeInteger") else 1 + for value, violates in ((5 * sign, True), (50 * sign, False)): + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.amount, Literal(str(value), datatype=XSD[datatype]))) + _, focus_nodes = _validate_rules(json.dumps(schema), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()), value + + +@pytest.mark.parametrize( + "slot_range,translated", + [ + *(pytest.param(r, True, id=r) for r in ("integer", "decimal", "float", "double")), + pytest.param("Count", True, id="custom-nonNegativeInteger"), + *( + pytest.param(r, False, id=r) + for r in ("string", "date", "datetime", "time", "boolean", "uriorcurie", "Kind", "Part") + ), + pytest.param("Year", False, id="custom-gYear"), + pytest.param("Span", False, id="custom-duration"), + ], +) +def test_compose_bounds_only_on_numeric_ranges(caplog, slot_range, translated): + """Bounds compare numerically, so they are translated on a type with a + numeric datatype and skip the rule on any other range: SPARQL orders no + string, date, duration, boolean, IRI or node against a number (§17.3).""" + schema = json.loads(_compose_schema(_rule({"amount": {"minimum_value": 1}}, _REQUIRE_NOTE))) + schema["types"] = { + "Count": {"typeof": "integer", "uri": "xsd:nonNegativeInteger"}, + "Year": {"typeof": "string", "uri": "xsd:gYear"}, + "Span": {"typeof": "string", "uri": "xsd:duration"}, + } + schema["classes"]["Thing"]["attributes"]["amount"] = {"range": slot_range} + with caplog.at_level(logging.WARNING): + g = _parse_shacl(json.dumps(schema)) + assert len(_sparql_queries(g, EX_COMP.Thing)) == (1 if translated else 0) + skipped = "its minimum_value on slot 'amount' needs a type with a numeric datatype, and the slot's range is " + skipped += repr(slot_range) + assert any(skipped in rec.message for rec in caplog.records) != translated, caplog.text + + +_LEVEL_ONE_OR_TWO = {"level": {"any_of": [{"equals_number": 1}, {"equals_number": 2}]}} +_LEVEL_NOT_ZERO_OR_TEN = {"level": {"none_of": [{"equals_number": 0}, {"equals_number": 10}]}} +_LEVEL_EXACTLY_ONE_BOUND = {"level": {"exactly_one_of": [{"minimum_value": 1}, {"maximum_value": 10}]}} +_LEVEL_BOTH_BOUNDS = {"level": {"all_of": [{"minimum_value": 1}, {"maximum_value": 10}]}} +_NOTE_A_OR_B_WORD = { + "note": {"any_of": [{"equals_string": "a"}, {"all_of": [{"pattern": "^b"}, {"equals_string_in": ["bb", "bc"]}]}]} +} + + +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +@pytest.mark.parametrize( + "pre,post,instance,valid", + [ + pytest.param({"level": {"equals_number": 3}}, _REQUIRE_NOTE, {"level": 3}, False, id="pre-equals-number"), + pytest.param({"level": {"equals_number": 3}}, _REQUIRE_NOTE, {"level": 4}, True, id="pre-equals-number-other"), + pytest.param( + _IF_MODE_M, {"level": {"equals_number": 3}}, {"mode": "m", "level": 3}, True, id="post-equals-number" + ), + pytest.param( + _IF_MODE_M, {"level": {"equals_number": 3}}, {"mode": "m", "level": 4}, False, id="post-equals-number-other" + ), + pytest.param({"mode": {"pattern": "^m"}}, _REQUIRE_NOTE, {"mode": "mx"}, False, id="pre-pattern"), + pytest.param({"mode": {"pattern": "^m"}}, _REQUIRE_NOTE, {"mode": "xm"}, True, id="pre-pattern-other"), + pytest.param(_IF_MODE_M, {"note": {"pattern": "^n"}}, {"mode": "m", "note": "nx"}, True, id="post-pattern"), + pytest.param( + _IF_MODE_M, {"note": {"pattern": "^n"}}, {"mode": "m", "note": "x"}, False, id="post-pattern-other" + ), + pytest.param(_LEVEL_ONE_OR_TWO, _REQUIRE_NOTE, {"level": 2}, False, id="pre-any-of"), + pytest.param(_LEVEL_ONE_OR_TWO, _REQUIRE_NOTE, {"level": 3}, True, id="pre-any-of-other"), + pytest.param(_IF_MODE_M, _LEVEL_NOT_ZERO_OR_TEN, {"mode": "m", "level": 10}, False, id="post-none-of"), + pytest.param(_IF_MODE_M, _LEVEL_NOT_ZERO_OR_TEN, {"mode": "m", "level": 5}, True, id="post-none-of-other"), + pytest.param(_IF_MODE_M, _LEVEL_EXACTLY_ONE_BOUND, {"mode": "m", "level": 0}, True, id="post-exactly-one-low"), + pytest.param( + _IF_MODE_M, _LEVEL_EXACTLY_ONE_BOUND, {"mode": "m", "level": 5}, False, id="post-exactly-one-both" + ), + pytest.param( + _IF_MODE_M, _LEVEL_EXACTLY_ONE_BOUND, {"mode": "m", "level": 11}, True, id="post-exactly-one-high" + ), + pytest.param(_IF_MODE_M, _LEVEL_BOTH_BOUNDS, {"mode": "m", "level": 5}, True, id="post-all-of"), + pytest.param(_IF_MODE_M, _LEVEL_BOTH_BOUNDS, {"mode": "m", "level": 11}, False, id="post-all-of-fails"), + pytest.param(_IF_MODE_M, _NOTE_A_OR_B_WORD, {"mode": "m", "note": "bb"}, True, id="post-nested-boolean"), + pytest.param(_IF_MODE_M, _NOTE_A_OR_B_WORD, {"mode": "m", "note": "bx"}, False, id="post-nested-boolean-fails"), + # a boolean combination, like any value test, holds for every value of a multivalued slot + pytest.param( + _IF_MODE_M, + {"levels": {"any_of": [{"equals_number": 1}, {"equals_number": 2}]}}, + {"mode": "m", "levels": [1, 2]}, + True, + id="post-any-of-every-value", + ), + pytest.param( + _IF_MODE_M, + {"levels": {"any_of": [{"equals_number": 1}, {"equals_number": 2}]}}, + {"mode": "m", "levels": [1, 3]}, + False, + id="post-any-of-one-value-fails", + ), + ], +) +def test_compose_value_tests_agree_with_json_schema(pre, post, instance, valid, target_class): + """``equals_number``, ``pattern`` and the boolean combinations of value tests decide each instance + as the JSON Schema generator's if/then does, in the declaring class and in a subclass.""" + schema = _compose_schema(_rule(pre, post), subclass=True) + assert len(_sparql_queries(_parse_shacl(schema), EX_COMP[target_class])) == 1 + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +@pytest.mark.parametrize("levels,violates", [([3, 9], False), ([3, 4], True), ([], True)]) +def test_compose_has_member_with_a_boolean_member(levels, violates): + """A ``has_member`` condition may combine value tests; it holds when some value satisfies the combination.""" + member = {"any_of": [{"equals_number": 1}, {"equals_number": 9}]} + schema = _compose_schema(_rule(_IF_MODE_M, {"levels": {"has_member": member}})) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.mode, Literal("m"))) + for level in levels: + data.add((EX_COMP.x, EX_COMP.levels, Literal(level))) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "level,violates", + [ + pytest.param(Literal(15), True, id="integer"), + pytest.param(Literal("15"), False, id="string"), + pytest.param(EX_COMP.fifteen, False, id="iri"), + ], +) +def test_compose_bound_fails_on_a_value_that_is_not_a_number(level, violates): + """A value that is not a number fails a numeric bound, since comparing it + is a type error (SPARQL 1.1 §17.3); its datatype violation is reported + by the property shape, not by the rule.""" + schema = _compose_schema(_rule({"level": {"minimum_value": 10}}, _REQUIRE_NOTE)) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.level, level)) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "rule,reason", + [ + pytest.param( + _rule({"level": {"equals_string": "3"}}, _REQUIRE_NOTE), + "whose range 'integer' is neither an enum nor a type with datatype xsd:string or xsd:boolean", + id="equals-string-on-integer", + ), + pytest.param( + _rule({"part": _inner(label={"maximum_value": 3})}, _REQUIRE_NOTE), + "its maximum_value on slot 'label' needs a type with a numeric datatype, and the slot's range is 'string'", + id="inner-bound-on-string", + ), + pytest.param( + _rule({"mode": {"equals_expression": "{note}"}}, _REQUIRE_NOTE), + "its precondition on slot 'mode' uses equals_expression", + id="pre-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"equals_number": 3}}), + "its equals_number on slot 'note' needs a type with a numeric datatype, and the slot's range is 'string'", + id="post-equals-number-on-string", + ), + pytest.param( + _rule({"level": {"any_of": [{"has_member": {"equals_number": 1}}]}}, _REQUIRE_NOTE), + "its any_of member on slot 'level' uses has_member", + id="boolean-member-op", + ), + pytest.param( + _rule({"level": {"exactly_one_of": [{"equals_number": 1}, {"description": "no test"}]}}, _REQUIRE_NOTE), + "its exactly_one_of member on slot 'level' uses no operator", + id="boolean-member-empty", + ), + pytest.param( + {"preconditions": {"slot_conditions": {}}, "postconditions": {"slot_conditions": _REQUIRE_NOTE}}, + "its preconditions constrain no slot", + id="empty-preconditions", + ), + pytest.param( + {"preconditions": {"slot_conditions": _IF_MODE_M}, "postconditions": {}}, + "its postconditions constrain no slot", + id="empty-postconditions", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"required": True, "equals_expression": "{mode}"}}), + "its postcondition on slot 'note' uses equals_expression, required", + id="post-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"required": False}}), + "its postcondition on slot 'note' constrains nothing", + id="post-not-required", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {}}, open_world=True), + "its postcondition on slot 'note' constrains nothing", + id="post-open-world-empty", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"value_presence": "UNCOMMITTED", "required": True}}), + "its postcondition on slot 'note' constrains nothing", + id="post-value-presence-overrides-required", + ), + pytest.param( + _rule(_IF_MODE_M, {"parts": {"has_member": {"equals_string": "x"}}}), + "equals_string(_in) on slot 'parts', whose range 'Part' is neither an enum", + id="has-member-value-on-class-range", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {"minimum_cardinality": 1}}}), + "its has_member on slot 'levels' uses minimum_cardinality", + id="has-member-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {"required": True}}}), + "its has_member on slot 'levels' uses required", + id="has-member-presence", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {}}}), + "its has_member on slot 'levels' uses no operator", + id="has-member-empty", + ), + pytest.param( + _rule({"level": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE), + "its range_expression is on slot 'level', whose range is not a class", + id="range-expression-on-non-class", + ), + pytest.param( + _rule( + {"part": {"range_expression": {"any_of": [{"slot_conditions": {"depth": {"maximum_value": 0}}}]}}}, + _REQUIRE_NOTE, + ), + "its range_expression on slot 'part' uses any_of,", + id="range-expression-any-of", + ), + pytest.param( + _rule( + { + "part": { + "range_expression": { + **_DEPTH_AT_MOST_ZERO["range_expression"], + "none_of": [{"slot_conditions": {"label": {"equals_string": "x"}}}], + } + } + }, + _REQUIRE_NOTE, + ), + "its range_expression on slot 'part' uses none_of, slot_conditions", + id="range-expression-slot-conditions-and-none-of", + ), + pytest.param( + _rule({"part": {"range_expression": {"slot_conditions": {}}}}, _REQUIRE_NOTE), + "its range_expression on slot 'part' uses no operator", + id="range-expression-empty", + ), + pytest.param( + _rule({"part": _inner(depth={"minimum_cardinality": 1})}, _REQUIRE_NOTE), + "its inner condition on slot 'depth' uses minimum_cardinality", + id="inner-op", + ), + pytest.param( + _rule({"part": _inner(nope={"maximum_value": 0})}, _REQUIRE_NOTE), + "'nope', which is not a slot", + id="inner-unknown-slot", + ), + pytest.param( + _rule(_IF_MODE_M, {"site": _inner(id={"equals_string": "ex:s1"})}), + "its condition is on the identifier slot 'id'", + id="inner-identifier", + ), + ], +) +def test_compose_untranslatable_rule_skipped(caplog, rule, reason): + """The compositional fallback is exact too: any operator it does not translate skips the rule, with the reason.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_compose_schema(rule)) + assert _sparql_queries(g, EX_COMP.Thing) == [] + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "skipped, because" in messages[0] and reason in messages[0], caplog.text + assert "in the shapes of" not in messages[0], "a problem with an inner slot belongs to the outer rule" + + +@pytest.mark.parametrize("bound", ['"abc"', "2020-01-01", "true", ".nan", ".inf", "1e20"]) +def test_compose_non_numeric_bound_skipped(caplog, bound): + """A ``minimum_value`` that is not a finite number (its metamodel range is + ``Anything``) skips the rule: interpolated, it would make the query + unparsable or compare as arithmetic. YAML 1.1 reads ``1e20``, which has + no dot, as a string.""" + marker = "__BOUND__" + schema = _compose_schema(_rule({"level": {"minimum_value": marker}}, _REQUIRE_NOTE)) + schema = schema.replace(f'"{marker}"', bound) # a raw YAML scalar + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + assert _sparql_queries(g, EX_COMP.Thing) == [] + assert any("is not a finite number" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_skip_replaces_problems_noted_while_translating(caplog): + """A rule skipped part-way through its translation reports only why it was + skipped: a problem noted earlier (a value no enum permits) describes a + constraint that is not emitted.""" + schema = _compose_schema( + _rule({"part": _inner(kind={"equals_string": "Bogus"}), "mode": {"equals_expression": "{note}"}}, _REQUIRE_NOTE) + ) + reason = "its precondition on slot 'mode' uses equals_expression" + with caplog.at_level(logging.WARNING): + _parse_shacl(schema) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "skipped, because" in messages[0] and reason in messages[0], messages + + +def test_rule_skip_in_a_subclass_keeps_the_problems_of_other_shapes(caplog): + """A rule skipped for a subclass, whose ``slot_usage`` changes a slot, keeps + the problems noted where it is translated.""" + schema = json.loads( + _compose_schema(_rule({"kind": {"equals_string": "Bogus"}}, _REQUIRE_NOTE), slots=["kind"], subclass=True) + ) + schema["classes"]["SubThing"]["slot_usage"] = {"kind": {"range": "integer"}} + with caplog.at_level(logging.WARNING): + g = _parse_shacl(json.dumps(schema)) + assert len(_sparql_queries(g, EX_COMP.Thing)) == 1 and _sparql_queries(g, EX_COMP.SubThing) == [] + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 2, messages + assert any("'Bogus', which is not a permissible value" in m and "in the shapes of" not in m for m in messages) + assert any("skipped, because" in m and "(in the shapes of 'SubThing')" in m for m in messages) + + +@pytest.mark.parametrize( + "part_usage,thing_usage,expected_iris,unexpected_iris", + [ + pytest.param( + {"kind": {"slot_uri": "ex:partKind"}}, {}, [EX_COMP.partKind], [EX_COMP.kind], id="inner-slot-uri-override" + ), + pytest.param( + {"kind": {"slot_uri": "ex:partKind"}}, + {"kind": {"slot_uri": "ex:thingKind"}}, + [EX_COMP.partKind], + [EX_COMP.thingKind], + id="inner-slot-not-shadowed-by-outer", + ), + pytest.param( + {}, {"part": {"range": "SpecialPart"}}, [EX_COMP.VerySpecial], [EX_COMP.Special], id="container-narrowed" + ), + ], +) +def test_compose_inner_slot_resolved_on_range_class(part_usage, thing_usage, expected_iris, unexpected_iris): + """Inner slots of a nested expression resolve in the induced context of the + container slot's range class: their IRI and their enum values come from it.""" + rule = _rule({"part": _inner(kind={"equals_string": "Special"})}, _REQUIRE_NOTE) + schema = _compose_schema( + rule, part_usage=part_usage, thing_usage=thing_usage, slots=["kind"] if "kind" in thing_usage else None + ) + (query,) = _sparql_queries(_parse_shacl(schema), EX_COMP.Thing) + for iri in expected_iris: + assert f"<{iri}>" in query, query + for iri in unexpected_iris: + assert f"<{iri}>" not in query, query + + +@pytest.mark.parametrize( + "values,violates", + [ + pytest.param("", True, id="both-absent"), + pytest.param(' ; ex:mode "m"', False, id="one-present"), + pytest.param(' ; ex:mode "m" ; ex:level 1', False, id="both-present"), + ], +) +def test_compose_several_absent_preconditions_each_hold(values, violates): + """Preconditions are a conjunction, so ``value_presence: ABSENT`` on two + slots requires both to be absent. The JSON Schema generator merges them + into one ``not: {required: [...]}``, "not all present", so the + expectations are explicit.""" + rule = _rule({"mode": {"value_presence": "ABSENT"}, "level": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE) + _, focus_nodes = _validate_rules(_compose_schema(rule), f"{_COMP_PREFIXES}ex:x a ex:Thing{values} .") + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "inner,site,violates", + [ + pytest.param({"maximum_value": 0}, "ex:s1 ex:depth -1 .", True, id="referenced-node-satisfies"), + pytest.param({"maximum_value": 0}, "ex:s1 ex:depth 5 .", False, id="referenced-node-fails"), + pytest.param({"maximum_value": 0}, "", True, id="referenced-node-not-described"), + pytest.param({"maximum_value": 0, "required": True}, "", False, id="required-on-undescribed-node"), + ], +) +def test_compose_range_expression_on_a_reference(inner, site, violates): + """On a slot that references a node instead of inlining it, a nested + condition applies to the referenced node's triples in the data graph; a + node the graph does not describe has none. JSON has only the identifier + there, so the expectations are explicit.""" + rule = _rule({"site": _inner(depth=inner)}, _REQUIRE_NOTE) + data = f"{_COMP_PREFIXES}ex:x a ex:Thing ; ex:site ex:s1 . {site}" + _, focus_nodes = _validate_rules(_compose_schema(rule), data) + assert focus_nodes == ({EX_COMP.x} if violates else set())