diff --git a/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py b/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py index 1210a31be2..7ffd11d45d 100644 --- a/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py +++ b/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py @@ -20,7 +20,7 @@ class ShaclDataType(DataType, Enum): FLOAT = ("float", URIRef("http://www.w3.org/2001/XMLSchema#float")) DOUBLE = ("double", URIRef("http://www.w3.org/2001/XMLSchema#double")) URI = ("uri", URIRef("http://www.w3.org/2001/XMLSchema#anyURI")) - CURIE = ("curi", URIRef("http://www.w3.org/2001/XMLSchema#string")) + CURIE = ("curie", URIRef("http://www.w3.org/2001/XMLSchema#string")) NCNAME = ("ncname", URIRef("http://www.w3.org/2001/XMLSchema#string")) OBJECT_IDENTIFIER = ("objectidentifier", URIRef("http://www.w3.org/ns/shex#iri")) NODE_IDENTIFIER = ("nodeidentifier", URIRef("http://www.w3.org/ns/shex#nonLiteral")) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..080b62589b 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -1,8 +1,10 @@ import logging +import math import os +import re import string from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, fields import click from jsonasobj2 import JsonObj, as_dict @@ -16,7 +18,17 @@ from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor from linkml.utils.generator import Generator, shared_arguments from linkml.utils.language_tags import LanguageTagResolver -from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum +from linkml_runtime.linkml_model.meta import ( + AnonymousClassExpression, + AnonymousSlotExpression, + ClassDefinition, + ClassRule, + Element, + ElementName, + PresenceEnum, + SlotDefinition, + SlotExpression, +) from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str @@ -59,6 +71,32 @@ 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. + + *cls* is the class whose shape the rule constrains, *owner* the class + that declares it, and *index* its 1-based position among the owner's + rules; *cls* differs from *owner* when the rule is inherited. + """ + + cls: ClassDefinition + owner: str + index: int + rule: ClassRule + + def __str__(self) -> str: + text = f"Rule {self.index} of class {self.owner!r}" + if self.rule.description: + text += f" ({self.rule.description})" + return text + + @dataclass class ShaclGenerator(Generator): """Generate SHACL (Shapes Constraint Language) shapes from a LinkML schema. @@ -145,15 +183,24 @@ 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: - - * *Boolean guard* — a precondition with ``value_presence: PRESENT`` on a - value slot and a postcondition with ``equals_string: "true"`` on a - boolean flag slot. - * *Exclusive value* — a precondition with ``equals_string`` on a slot and - a postcondition with ``maximum_cardinality`` on the *same* slot. + 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 + ``equals_string_in`` on a target slot holding strings (an enum, or a type + with datatype ``xsd:string``) or booleans. The *boolean guard* is the + case ``equals_string: "true"`` on an ``xsd:boolean``-typed flag. + * *Exclusive value* — a precondition with ``has_member: {equals_string: V}`` + (or a bare ``equals_string: V``) on a slot and a postcondition with + ``maximum_cardinality`` on the *same* slot. + + Any other rule with one postcondition slot is composed from the operators + its conditions use (presence, value comparisons, numeric bounds, + ``range_expression``, ``has_member``). 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 `_. @@ -200,6 +247,9 @@ def serialize(self, **args) -> str: def as_graph(self) -> Graph: sv = self.schemaview + # Problems with rules, collected while the rules are translated for + # every class that has them and reported once each at the end. + self._rule_problems: dict[tuple[str, int, str], tuple[str, list[str]]] = {} g = Graph() g.bind("sh", SH) @@ -408,6 +458,7 @@ def st_node_pv(p, v): if self.emit_rules: self._add_rules(g, class_uri_with_suffix, c) + self._report_rule_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" @@ -417,227 +468,871 @@ def st_node_pv(p, v): # ------------------------------------------------------------------- def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: - """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. + """Emit ``sh:sparql`` constraints from the LinkML ``rules`` that apply to *cls*. Each recognised rule is converted into an ``sh:SPARQLConstraint`` - attached to *shape_uri*. Unrecognised patterns are logged at - ``DEBUG`` level and silently skipped. + attached to *shape_uri*. Currently recognised patterns: + + * **Presence implies value** — a *precondition* with + ``value_presence: PRESENT`` on a guard slot and a *postcondition* + with ``equals_string`` or ``equals_string_in`` on a target slot. + When the guard is present, the target must be present and hold one + of the allowed values. The target must hold strings (an enum, or a + type with datatype ``xsd:string``) or booleans (``xsd:boolean``). + The **boolean guard** is the case ``equals_string: "true"`` on a + boolean flag. + + * **Exclusive value** — a *precondition* with ``has_member: + {equals_string: V}`` on a slot and a *postcondition* with + ``maximum_cardinality`` on the *same* slot. When V is one of the + slot's values, the slot holds at most that many values (typically 1 + for mutual exclusion). A bare ``equals_string: V`` precondition on a + multivalued slot is read the same way, with a warning, although the + specification applies ``equals_string`` to all members of a + collection. + + * **Composed** — any other rule with one postcondition slot is + composed from the operators its conditions use: presence, + ``equals_string(_in)`` and numeric bounds on each value, a + ``range_expression`` on the slot's class, and ``has_member`` (see + :meth:`_compose_rule_sparql`). + + Apart from that reading, every emitted constraint translates its rule + exactly. A rule that cannot be translated exactly is skipped with a + warning naming the 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 + as well, as it carries their slots, and as the JSON Schema generator + applies them. Each rule is translated in the context of *cls*, where + ``slot_usage`` may refine a slot the rule references. - Currently recognised patterns: + See `W3C SHACL §5 `_. + """ + sv = self.schemaview + for owner in sv.class_ancestors(cls.name): + for index, rule in enumerate(sv.get_class(owner).rules, start=1): + self._add_rule(g, shape_uri, _RuleSite(cls, owner, index, rule)) + + def _add_rule(self, g: Graph, shape_uri: URIRef, site: _RuleSite) -> None: + """Emit the ``sh:SPARQLConstraint`` of the rule at *site* on *shape_uri*, unless it is skipped.""" + rule = site.rule + if rule.deactivated: + return + 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: + 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 - * **Boolean guard** — a *precondition* with - ``value_presence: PRESENT`` on a value slot and a *postcondition* - with ``equals_string: "true"`` on a boolean flag slot. + 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))) - * **Exclusive value** — a *precondition* with ``equals_string`` on - a slot and a *postcondition* with ``maximum_cardinality`` on the - *same* slot. Enforces that when a specific value is present in a - multivalued slot, the total number of values must not exceed the - given cardinality (typically 1 for mutual exclusion). + @staticmethod + def _has_sparql_constraint(g: Graph, shape_uri: URIRef, query: Literal, message: Literal | None) -> bool: + """Whether the shape *shape_uri* already carries the constraint with *query* and *message*. - See `W3C SHACL §5 `_. + Classes that share a ``class_uri`` share one shape when shapes are named + by ``class_uri`` (the default), so a rule they all inherit would + otherwise be added once per class and reported twice. """ - if not cls.rules: - return + return any( + (constraint, SH.select, query) in g and set(g.objects(constraint, SH.message)) == {message} - {None} + for constraint in g.objects(shape_uri, SH.sparql) + ) - sv = self.schemaview - for rule in cls.rules: - if getattr(rule, "deactivated", False): - continue + def _warn_rule(self, site: _RuleSite, problem: str) -> None: + """Record *problem* with the rule at *site* for the shape of ``site.cls``. - if getattr(rule, "bidirectional", False): - logger.warning( - "Rule in class %r has bidirectional=true; " - "SHACL-SPARQL generation does not support bidirectional rules. " - "Skipping this rule entirely.", - cls.name, - ) - continue + A rule is translated once for every class that has it, declared or + inherited, so the same problem can arise several times; it is reported + once, by :meth:`_report_rule_problems`. + """ + _, classes = self._rule_problems.setdefault((site.owner, site.index, problem), (str(site), [])) + if site.cls.name not in classes: + classes.append(site.cls.name) + + def _report_rule_problems(self) -> None: + """Log each recorded rule problem once, naming the class shapes it affects + unless that is only the class declaring the rule.""" + for (owner, _, problem), (rule, classes) in self._rule_problems.items(): + shapes = "" if classes == [owner] else f" (in the shapes of {', '.join(map(repr, classes))})" + 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. + + 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 + # semantics: they never change which instances satisfy the condition, so + # they are ignored by the operator accounting below. Anything set on a + # condition that is neither here nor explicitly translated by a converter + # makes the rule untranslatable — the converters must SKIP such a rule + # rather than emit a query that silently drops a conjunct (which would + # widen the trigger or narrow the check: a mis-translation, not a skip). + # Derived from the metamodel: the metadata every ``element`` carries, minus + # anything that is a ``slot_expression`` operator. + _NON_OPERATOR_FIELDS = frozenset(f.name for f in fields(Element)) - frozenset( + f.name for f in fields(SlotExpression) + ) - if getattr(rule, "open_world", False): - logger.warning( - "Rule in class %r has open_world=true; " - "SHACL operates under closed-world assumption. " - "The constraint is emitted but may not match open-world semantics.", - cls.name, - ) + # The lexical space of xsd:boolean and the value each lexical form maps to + # (XML Schema 1.1 Part 2 §3.3.2.2, ), + # as SPARQL boolean literals. + _XSD_BOOLEAN_LEXICAL = {"true": "true", "1": "true", "false": "false", "0": "false"} - if getattr(rule, "elseconditions", None): - logger.warning( - "Rule in class %r has elseconditions; " - "only the forward (if/then) branch is emitted as sh:sparql. " - "The else branch cannot be represented in SHACL-SPARQL.", - cls.name, - ) + # 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( + cls, condition: SlotDefinition | AnonymousSlotExpression | AnonymousClassExpression + ) -> set[str]: + """Return the names of the constraint-bearing fields actually set on a + rule condition or class expression. + + A field counts as *set* when it is not ``None`` and not an empty + collection (SchemaView materialises unset multivalued fields as empty + lists / dicts). Scalars are never judged by truthiness, so legitimate + falsy constraints such as ``minimum_value: 0`` or + ``equals_string: ""`` still count as set. Metadata fields + (:data:`_NON_OPERATOR_FIELDS`) are excluded. + + The converters compare this set against the exact operator set they + translate and skip the rule on any mismatch, so an unrecognised (or + future-metamodel) operator can never be silently dropped. + """ + return { + name + for name, value in vars(condition).items() + if not name.startswith("_") + and name not in cls._NON_OPERATOR_FIELDS + and value is not None + and not (isinstance(value, list | dict) and not value) + } - sparql_query = self._rule_to_sparql(sv, cls, rule) - if sparql_query is None: - logger.debug( - "Skipping unsupported rule pattern in class %r: %s", - cls.name, - getattr(rule, "description", "(no description)"), + def _rule_to_sparql(self, site: _RuleSite) -> str | None: + """Translate the rule at *site* to a SPARQL SELECT query. + + Returns ``None``, after a warning naming the reason, when the rule + matches no supported pattern exactly. Each pattern requires its + conditions to set **exactly** the operators it translates; a rule whose + pre/postconditions carry anything more (extra scalar operators, + expression-level ``any_of``/``all_of``/``none_of``/``exactly_one_of``, + ...) is skipped rather than partially translated, since dropping a term + would widen the precondition (false positives) or weaken the + postcondition (false negatives). + """ + pre, post = site.rule.preconditions, site.rule.postconditions + if pre is None or post is None: + self._skip_rule(site, "only rules with preconditions and postconditions are translated") + return None + for side, expression in (("preconditions", pre), ("postconditions", post)): + untranslated = self._set_operator_fields(expression) - {"slot_conditions"} + 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: + 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() + pre_slot = self._rule_condition_slot(site, pre_name) + post_slot = self._rule_condition_slot(site, post_name) if pre_slot is not None else None + if pre_slot is None or post_slot is None: + return None + pre_ops = self._set_operator_fields(pre_cond) + post_ops = self._set_operator_fields(post_cond) + + # Presence implies value: "if the guard is present, the target must be + # present and hold one of the allowed values". The boolean guard is + # the case `equals_string: "true"` on a boolean flag. + if ( + pre_ops == {"value_presence"} + 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)) + + # Exclusive value: "if V is one of the values of slot X, then X has at + # most N values". + if post_ops == {"maximum_cardinality"} and pre_slot.name == post_slot.name: + exclusive = self._exclusive_value(site, pre_slot, pre_cond, pre_ops) + if exclusive is not 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._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) + + def _exclusive_value( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, operators: set[str] + ) -> str | None: + """The value V of an exclusive-value precondition on *slot*, or ``None`` if *condition* is not one. + + ``has_member: {equals_string: V}`` states "V is one of the values" + (metamodel ``has_member``: "at least one member satisfying the + condition"). A bare ``equals_string: V`` on a multivalued slot is read + the same way, as the pattern always has, with a warning: the + specification applies a slot constraint to all members of a collection + (``05validation.md``), under which the rule would mean something else. + Every other rule reads it that way (:meth:`_compose_rule_sparql`). + """ + if operators == {"has_member"} and self._set_operator_fields(condition.has_member) == {"equals_string"}: + return condition.has_member.equals_string + if operators == {"equals_string"}: + if slot.multivalued: + self._warn_rule( + site, + f"its precondition equals_string on the multivalued slot {slot.name!r} is read as " + "'one of the values equals', which has_member: {equals_string: ...} states explicitly; " + "the specification applies equals_string to all members of a collection", ) - continue + return condition.equals_string + return None - constraint = BNode() - g.add((shape_uri, SH.sparql, constraint)) - g.add((constraint, RDF.type, SH.SPARQLConstraint)) + # Operators the compositional translation supports on a condition: those a + # value is tested against, and those stating whether the slot is present or + # some value satisfies a condition. + _VALUE_OPERATORS = frozenset( + {"equals_string", "equals_string_in", "minimum_value", "maximum_value", "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", + ) + ) - message = getattr(rule, "description", None) - if message: - g.add((constraint, SH.message, Literal(message))) + def _compose_rule_sparql( + self, site: _RuleSite, pre: dict[str, SlotDefinition], post: dict[str, SlotDefinition] + ) -> str | None: + """Compose the query 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``, and the single postcondition the union of the + ways to violate it, so the query selects the focus nodes that satisfy + every precondition and violate the postcondition (`SHACL §5.3.1 + `_), with + the offending value where there is one (`SHACL §5.3.2 + `_). + + 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. + """ + if len(post) != 1: + self._skip_rule(site, f"{self._NO_PATTERN}, and its postconditions constrain more than one slot") + return None + lines: 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 + filters = self._precondition_filters(site, slot, condition, f"?pre{index}") + if filters is None: + return None + lines.extend(filters) + ((post_name, post_cond),) = 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 + 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) + return f"SELECT DISTINCT $this (<{self._slot_iri(post_slot)}> AS ?path) ?value WHERE {{\n{body}\n}}" + + @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 - g.add((constraint, SH.select, Literal(sparql_query))) + 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 _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None: - """Convert a ``ClassRule`` to a SPARQL SELECT query string. + 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*. - Returns ``None`` when the rule does not match any supported pattern. + 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. + ``minimum_value`` / ``maximum_value`` are inclusive bounds (metamodel), + compared numerically, so the slot's datatype must be one SPARQL + compares as a number (:data:`_SPARQL_NUMERIC_DATATYPES`). 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)) + bounds = { + name: (bound, operator) + for name, bound, operator in ( + ("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 )") + return " && ".join(parts) + + @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. """ - pre = getattr(rule, "preconditions", None) - post = getattr(rule, "postconditions", None) - if not pre or not post: + 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) - pre_slots = getattr(pre, "slot_conditions", None) or {} - post_slots = getattr(post, "slot_conditions", None) or {} + @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))}" + ) - # Pattern: boolean guard - # preconditions: exactly one slot with value_presence PRESENT - # postconditions: exactly one slot with equals_string "true" - if len(pre_slots) == 1 and len(post_slots) == 1: - pre_slot_name = next(iter(pre_slots)) - post_slot_name = next(iter(post_slots)) + 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. - pre_cond = pre_slots[pre_slot_name] - post_cond = post_slots[post_slot_name] + Resolution order mirrors ``sh:path`` in the main slot loop: the induced + (class-specific) slot when the key names one of the class's slots, then + the underscored alias form (a rule key ``my_slot`` for a slot named + ``my slot`` — SchemaView normalises names the same way elsewhere), then + the base slot. + """ + sv = self.schemaview + class_slot_names = sv.class_slots(cls.name) + if slot_name in class_slot_names: + return sv.induced_slot(slot_name, cls.name) + canonical = next((s for s in class_slot_names if underscore(s) == underscore(slot_name)), None) + if canonical is not None: + return sv.induced_slot(canonical, cls.name) + return sv.get_slot(slot_name) + + 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(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 + if slot.identifier: + self._skip_rule( + site, f"its condition is on the identifier slot {slot_name!r}, which is the node IRI, not a property" + ) + return None + return slot - # Note: PresenceEnum.PRESENT is a PermissibleValue, but parsed schemas - # return PresenceEnum instances — wrapping ensures type-compatible comparison. - is_value_present = getattr(pre_cond, "value_presence", None) == PresenceEnum(PresenceEnum.PRESENT) - is_flag_true = getattr(post_cond, "equals_string", None) == "true" + def _slot_range(self, slot: SlotDefinition) -> ElementName | None: + """The range of *slot*, defaulting to the schema's ``default_range`` as an induced slot does.""" + return slot.range or self.schemaview.schema.default_range - if is_value_present and is_flag_true: - return self._build_boolean_guard_sparql(sv, cls, post_slot_name, pre_slot_name) + def _slot_iri(self, slot: SlotDefinition) -> str: + """The full IRI of *slot*, exactly as ``sh:path`` in the main slot loop renders it. - # Pattern: exclusive value - # preconditions: slot X has equals_string (a specific enum value) - # postconditions: same slot X has maximum_cardinality N - # Semantics: "If value V is present in slot X, then X has at most N values." - pre_equals = getattr(pre_cond, "equals_string", None) - post_max_card = getattr(post_cond, "maximum_cardinality", None) + An induced slot carries its ``slot_usage`` overrides, so an overridden + ``slot_uri`` yields the same IRI as ``sh:path``; otherwise the query + would use a property the data never uses and never fire. + """ + sv = self.schemaview + if slot.name in sv.element_by_schema_map(): + return sv.get_uri(slot, expand=True) + return sv.expand_curie(f"{sv.schema.default_prefix}:{underscore(slot.name)}") - if pre_equals is not None and post_max_card is not None and pre_slot_name == post_slot_name: - return self._build_exclusive_value_sparql(sv, cls, pre_slot_name, pre_equals, int(post_max_card)) + def _type_uri(self, r: ElementName | None) -> str | None: + """The expanded datatype IRI of type range *r*, or ``None`` when *r* is not a type. + Resolved through the induced type, so a type derived with ``typeof`` + inherits the ``uri`` of its ancestor. A built-in type name in a schema + that does not import ``linkml:types`` resolves as the main slot loop + resolves it (:class:`ShaclDataType`). + """ + sv = self.schemaview + if r in sv.all_types(): + return sv.get_uri(sv.induced_type(r), expand=True) + builtin = next((t for t in ShaclDataType if t.linkml_type == r), None) + return str(builtin.uri_ref) if builtin is not None else None + + def _is_string_range(self, r: ElementName | None) -> bool: + """Whether a slot with range *r* holds strings, which ``equals_string`` compares against. + + True for an enum, whose permissible values are rendered as their + ``meaning`` IRI or as a plain literal (as :meth:`_add_enum` renders + them); for a type whose datatype is ``xsd:string``, whose values are + plain literals; and for no range at all, whose values are untyped and + compared as strings, as the JSON Schema generator compares them. A + type with any other datatype, including one derived from ``string`` + (``xsd:anyURI``, ``xsd:token``, ...), holds typed literals or IRIs + that a string literal does not match. + """ + if r is None or r in self.schemaview.all_enums(): + return True + return self._type_uri(r) == str(XSD.string) + + def _value_terms(self, site: _RuleSite, slot: SlotDefinition, values: list[str]) -> list[str] | None: + """The SPARQL terms of the equals_string(_in) *values* on *slot*, or ``None`` (rule skipped). + + The metamodel defines both operators for slots of range ``string``. + Enums, whose values are strings in instance data, and types with + datatype ``xsd:string`` are treated alike (:meth:`_string_value_term`). + A slot whose range is ``xsd:boolean``-typed holds booleans, as the + boolean guard compares them: each value must be a lexical form of + ``xsd:boolean`` and becomes the boolean it denotes, compared by value. + On any other range the RDF data holds typed literals + (``"3"^^xsd:integer``) or IRIs that no string equals, so the + constraint would report conforming data or never fire. + """ + r = self._slot_range(slot) + if self._is_string_range(r): + return [self._string_value_term(site, slot, value) for value in values] + if self._type_uri(r) == str(XSD.boolean): + terms = [self._XSD_BOOLEAN_LEXICAL.get(value) for value in values] + if None not in terms: + return terms + self._skip_rule( + site, + f"equals_string(_in) on the boolean slot {slot.name!r} with {values!r}, " + "which are not all xsd:boolean lexical forms (true, false, 1, 0)", + ) + return None + self._skip_rule( + site, + f"equals_string(_in) on slot {slot.name!r}, whose range {r!r} is neither an enum " + "nor a type with datatype xsd:string or xsd:boolean", + ) return None - def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str) -> str: - """Build a SPARQL SELECT query for the boolean-guard pattern. + def _string_value_term(self, site: _RuleSite, slot: SlotDefinition, value: str) -> str: + """The SPARQL term of the ``equals_string`` *value* of string-valued *slot*. + + A permissible value of an enum range is rendered as :meth:`_add_enum` + renders it, as the IRI of its ``meaning`` where it has one; anything + else is a string literal. A value that is not a permissible value of + an enum with static permissible values is reported: no valid value of + the slot can equal it. + """ + sv = self.schemaview + r = self._slot_range(slot) + if r in sv.all_enums(): + permissible_values = sv.get_enum(r).permissible_values + pv = permissible_values.get(value) + if pv is not None and pv.meaning: + return f"<{sv.expand_curie(pv.meaning)}>" + if pv is None and permissible_values: + self._warn_rule( + site, + f"it compares slot {slot.name!r} with {value!r}, which is not a permissible value of enum {r!r}", + ) + return self._sparql_string_literal(value) + + @staticmethod + def _sparql_string_literal(value: str) -> str: + """Render *value* as a SPARQL expression for that string. + + The characters the grammar forbids raw are escaped (`SPARQL 1.1 §19.7 + `_), so a value + with a double quote, backslash or newline neither breaks the + ``sh:select`` query nor injects into it. Codepoint escapes are replaced + before parsing (`§19.2 `_), + so an escaped backslash followed by ``u`` / ``U`` would be read as one: + the literal is split there and rejoined with ``CONCAT``. + """ + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + parts = re.split(r"(?<=\\)(?=[uU])", escaped) + if len(parts) == 1: + return f'"{escaped}"' + return "CONCAT(" + ", ".join(f'"{part}"' for part in parts) + ")" + + @staticmethod + def _sparql_is_one_of(var: str, terms: list[str]) -> str: + """A SPARQL expression that is true when *var* equals one of *terms*, and false otherwise. + + The test is spelled out as ``var = t1 || var = t2 || ...``, which is how + `SPARQL 1.1 §17.4.1.9 `_ + defines ``IN``. The ``=`` operator compares values, so a plain literal + also matches its RDF 1.1-identical ``xsd:string`` form (`RDF 1.1 + Concepts §3.3 `_), + which JSON-LD produces under ``"@type": "xsd:string"`` coercion; some + engines, rdflib among them, match ``IN`` and triple-pattern constants by + term identity and miss that form. ``COALESCE`` (§17.4.1.3) turns the + type error that ``RDFterm-equal`` (§17.4.1.7) raises for incomparable + literals into false: such a value equals none of *terms*, and an + unguarded error would drop the row and hide the violation. An unbound + *var* is false as well. + """ + disjunction = " || ".join(f"{var} = {term}" for term in terms) + return f"COALESCE( {disjunction}, false )" + + def _build_presence_implies_value_sparql( + self, guard: SlotDefinition, target: SlotDefinition, terms: list[str], open_world: bool + ) -> str: + """Build the SPARQL SELECT query of the presence-implies-value pattern. - The query detects violations where the value property is present - but the boolean flag is absent or not ``true``. + A focus node violates the rule when the *guard* slot is present and the + *target* slot is absent or holds a value that equals none of the SPARQL + *terms* (see :meth:`_sparql_is_one_of`). The boolean guard is this + pattern with the boolean ``true`` as the only term. With *open_world*, + "the postconditions may be omitted in instance data" (metamodel + ``open_world``), so an absent target is no violation. Conforms to `SHACL §5.3.1 - `_: - ``$this`` is pre-bound to each focus node. + `_ (``$this`` + is pre-bound to each focus node) and projects ``?path`` and ``?value`` + as result variables (`§5.3.2 + `_), so each + result names the target property and the offending value. """ - flag_uri = self._slot_uri(sv, flag_slot_name, cls) - value_uri = self._slot_uri(sv, value_slot_name, cls) - + guard_iri = self._slot_iri(guard) + target_iri = self._slot_iri(target) + is_allowed = self._sparql_is_one_of("?value", terms) + if open_world: + target_pattern = f"$this <{target_iri}> ?value ." + violation = f"!{is_allowed}" + else: + target_pattern = f"OPTIONAL {{ $this <{target_iri}> ?value . }}" + violation = f"!BOUND(?value) || !{is_allowed}" return ( - f"SELECT $this WHERE {{\n" - f" OPTIONAL {{ $this <{flag_uri}> ?flag . }}\n" - f" OPTIONAL {{ $this <{value_uri}> ?value . }}\n" - f" FILTER (\n" - f' ( !BOUND(?flag) || str(?flag) != "true" ) &&\n' - f" BOUND(?value)\n" - f" )\n" + f"SELECT DISTINCT $this (<{target_iri}> AS ?path) ?value WHERE {{\n" + f" $this <{guard_iri}> ?guard .\n" + f" {target_pattern}\n" + f" FILTER ( {violation} )\n" f"}}" ) - def _build_exclusive_value_sparql( - self, - sv, - cls: ClassDefinition, - slot_name: str, - value_name: str, - max_card: int, - ) -> str | None: - """Build a SPARQL SELECT query for the exclusive-value pattern. - - Detects violations where a specific value is present in a multivalued - slot but the total number of values exceeds *max_card*. + def _build_exclusive_value_sparql(self, slot: SlotDefinition, term: str, max_card: int) -> str: + """Build the SPARQL SELECT query of the exclusive-value pattern. - For the common case ``max_card == 1``, the query checks whether the - exclusive value coexists with any other value (simple existence test). - For ``max_card > 1``, a subquery counts all values and checks against - the limit. - - The exclusive value is resolved to its full IRI via the slot's enum - ``meaning`` field. If the slot is not an enum or the value has no - ``meaning``, the value is compared as a plain literal. + A focus node violates the rule when *slot* holds a value equal to the + SPARQL *term* (see :meth:`_sparql_is_one_of`) and more than *max_card* + values in total. For ``max_card == 1`` the query reports each other + value that coexists with the exclusive one. For ``max_card > 1`` a + subquery counts all values, testing ``HAVING`` on the ``COUNT`` + aggregate itself: expressions projected by ``SELECT`` are not visible + to ``HAVING`` (`SPARQL 1.1 §18.2.4.2 + `_), so a + projected alias would be unbound there and the constraint never fire. Conforms to `SHACL §5.3.1 - `_: - ``$this`` is pre-bound to each focus node. + `_ and + projects ``?path`` (and, for ``max_card == 1``, the coexisting value as + ``?value``) as result variables (`§5.3.2 + `_). """ - slot_uri = self._slot_uri(sv, slot_name, cls) - value_ref = self._resolve_enum_value_ref(sv, slot_name, value_name) - + iri = self._slot_iri(slot) + is_exclusive = self._sparql_is_one_of("?exclusive", [term]) if max_card == 1: + is_other_exclusive = self._sparql_is_one_of("?other", [term]) return ( - f"SELECT $this WHERE {{\n" - f" $this <{slot_uri}> {value_ref} .\n" - f" $this <{slot_uri}> ?other .\n" - f" FILTER (?other != {value_ref})\n" + f"SELECT DISTINCT $this (<{iri}> AS ?path) (?other AS ?value) WHERE {{\n" + f" $this <{iri}> ?exclusive .\n" + f" $this <{iri}> ?other .\n" + f" FILTER ( {is_exclusive} && !{is_other_exclusive} )\n" f"}}" ) - return ( - f"SELECT $this WHERE {{\n" - f" $this <{slot_uri}> {value_ref} .\n" + f"SELECT DISTINCT $this (<{iri}> AS ?path) WHERE {{\n" + f" $this <{iri}> ?exclusive .\n" + f" FILTER ( {is_exclusive} )\n" f" {{\n" - f" SELECT $this (COUNT(?val) AS ?count)\n" - f" WHERE {{ $this <{slot_uri}> ?val . }}\n" + f" SELECT $this\n" + f" WHERE {{ $this <{iri}> ?item . }}\n" f" GROUP BY $this\n" - f" HAVING (?count > {max_card})\n" + f" HAVING ( COUNT(?item) > {max_card} )\n" f" }}\n" f"}}" ) - def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str) -> str: - """Resolve an enum value name to a SPARQL term (IRI or literal). - - Looks up the slot's range as an enum, finds the permissible value - matching *value_name*, and returns its ``meaning`` as a full IRI - wrapped in angle brackets. Falls back to a quoted literal if the - slot is not an enum or the value lacks a ``meaning``. - """ - slot = sv.get_slot(slot_name) - if slot: - range_name = slot.range - if range_name and range_name in sv.all_enums(): - enum = sv.get_enum(range_name) - pv = enum.permissible_values.get(value_name) - if pv and pv.meaning: - iri = sv.expand_curie(pv.meaning) - return f"<{iri}>" - return f'"{value_name}"' - - def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str: - """Resolve a slot name to a full IRI string for use in SPARQL queries. - - Mirrors the resolution logic used for ``sh:path`` in the main slot loop: - prefer ``sv.get_uri()`` for slots registered in the schema map, fall - back to ``default_prefix:underscored_name``. - """ - slot = sv.get_slot(slot_name) - if slot and slot_name in sv.element_by_schema_map(): - return sv.get_uri(slot, expand=True) - pfx = sv.schema.default_prefix - return sv.expand_curie(f"{pfx}:{underscore(slot_name)}") - def _add_class(self, func: Callable, r: ElementName) -> None: """Add a class/shape constraint for range class *r*. @@ -924,8 +1619,9 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: show_default=True, help=( "Emit sh:sparql constraints from LinkML rules: blocks. " - "When enabled (default), recognised rule patterns (e.g. boolean-guard) " - "are translated into SHACL-SPARQL constraints on the corresponding " + "When enabled (default), recognised rule patterns (boolean-guard, " + "presence-implies-value, exclusive-value) are translated into " + "SHACL-SPARQL constraints on the corresponding " "sh:NodeShape. Use --no-emit-rules to suppress rule generation." ), ) diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index f367de97e7..c5c568131e 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -1,9 +1,12 @@ +import json +import logging +import re from collections import Counter from typing import Any import pytest import rdflib -from rdflib import RDF, RDFS, SH, Literal, URIRef +from rdflib import RDF, RDFS, SH, XSD, Literal, URIRef from rdflib.collection import Collection from linkml.generators.shacl.shacl_data_type import ShaclDataType @@ -2108,7 +2111,7 @@ def test_rule_deactivated_skipped(): def test_rule_unsupported_pattern_skipped(): - """Unrecognised rule patterns are silently skipped (no sh:sparql emitted).""" + """Unrecognised rule patterns are skipped (no sh:sparql emitted).""" g = _parse_shacl(_UNSUPPORTED_RULE_SCHEMA_YAML) shape = URIRef("https://example.org/unsupported-test/TestClass") @@ -2585,13 +2588,14 @@ def test_exclusive_value_sparql_uses_enum_iri(): def test_exclusive_value_max_card_1_sparql_structure(): - """For maximum_cardinality: 1, SPARQL uses FILTER(?other != ). + """For maximum_cardinality: 1, SPARQL reports each ?other value coexisting with . The query pattern for N=1 is: - SELECT $this WHERE { - $this . + SELECT DISTINCT $this ( AS ?path) (?other AS ?value) WHERE { + $this ?exclusive . $this ?other . - FILTER (?other != ) + FILTER ( COALESCE( ?exclusive = , false ) && + !COALESCE( ?other = , false ) ) } This is more efficient than the COUNT-based approach for the common @@ -2616,13 +2620,14 @@ def test_exclusive_value_max_card_gt1_sparql_structure(): """For maximum_cardinality > 1, SPARQL uses COUNT-based subquery. The query pattern for N>1 is: - SELECT $this WHERE { - $this . + SELECT DISTINCT $this ( AS ?path) WHERE { + $this ?exclusive . + FILTER ( COALESCE( ?exclusive = , false ) ) { - SELECT $this (COUNT(?val) AS ?count) - WHERE { $this ?val . } + SELECT $this + WHERE { $this ?item . } GROUP BY $this - HAVING (?count > N) + HAVING ( COUNT(?item) > N ) } } """ @@ -2833,3 +2838,2751 @@ def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: graph.parse(data=ShaclGenerator(str(domain)).serialize(), format="turtle") shapes = set(graph.subjects(RDF.type, SH.NodeShape)) assert URIRef("https://example.org/domain/Pedido") in shapes + + +# =========================================================================== +# Presence-implies-value pattern tests (enum guard) +# =========================================================================== +# +# The "presence implies value" pattern generalises the boolean guard to +# enum-valued targets. It translates a LinkML rule where: +# - preconditions: a guard slot has value_presence: PRESENT +# - postconditions: a target slot has equals_string (single required value) +# or equals_string_in (a set of acceptable values) +# +# Semantics: "If the guard slot is present, the target slot must be present +# and hold one of the allowed values", e.g. "if a document has a signature, +# its status must be Published". +# +# References: +# - W3C SHACL §5 +# - W3C SHACL §5.3.1 +# - W3C SHACL §5.3.2 +# =========================================================================== + +_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML = """ +id: https://example.org/presence-implies-value +name: presence_implies_value_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/presence-implies-value/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + StatusEnum: + permissible_values: + Draft: + meaning: ex:Draft + Reviewed: + meaning: ex:Reviewed + Approved: + meaning: ex:Approved + Published: + meaning: ex:Published + + ModeEnum: + permissible_values: + Auto: + description: Automatic mode (no meaning IRI). + Manual: + description: Manual mode (no meaning IRI). + +slots: + status: + range: StatusEnum + slot_uri: ex:status + signature: + range: string + slot_uri: ex:signature + review_score: + range: float + slot_uri: ex:review_score + mode: + range: ModeEnum + slot_uri: ex:mode + manual_value: + range: decimal + slot_uri: ex:manual_value + +classes: + Document: + class_uri: ex:Document + slots: + - status + - signature + - review_score + rules: + - description: If a document has a signature, its status must be Published. + preconditions: + slot_conditions: + signature: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "Published" + - description: If a document has a review score, its status must be Reviewed or Approved. + preconditions: + slot_conditions: + review_score: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string_in: + - Reviewed + - Approved + + Device: + class_uri: ex:Device + slots: + - mode + - manual_value + rules: + - description: If manual_value is provided, mode must be Manual (literal fallback). + preconditions: + slot_conditions: + manual_value: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: "Manual" +""" + +EX_PIV = rdflib.Namespace("https://example.org/presence-implies-value/") + + +def _rule_results(schema: str, data: str | rdflib.Graph) -> tuple[bool, list[tuple]]: + """Validate *data* against the shapes generated for *schema*. + + *data* is Turtle or a graph. Returns whether the data conforms overall, + and the ``(focus node, result path, value)`` of each result that a rule + constraint (``sh:sparql``) reports, so that a property-shape result + (datatype, cardinality, ...) can neither mask nor stand in for what a rule + decides. + """ + import pyshacl + + shapes = rdflib.Graph().parse(data=ShaclGenerator(schema, mergeimports=False).serialize(), format="turtle") + data_graph = data if isinstance(data, rdflib.Graph) else rdflib.Graph().parse(data=data, format="turtle") + conforms, report, _ = pyshacl.validate(data_graph, shacl_graph=shapes, advanced=True) + results = [ + (report.value(result, SH.focusNode), report.value(result, SH.resultPath), report.value(result, SH.value)) + for result in report.subjects(SH.sourceConstraintComponent, SH.SPARQLConstraintComponent) + ] + return conforms, results + + +def _validate_rules(schema: str, data: str | rdflib.Graph) -> tuple[bool, set]: + """Whether *data* conforms to the shapes of *schema*, and the focus nodes its rule constraints report.""" + conforms, results = _rule_results(schema, data) + return conforms, {focus_node for focus_node, _, _ in results} + + +def _sparql_queries(g: rdflib.Graph, shape: URIRef) -> list[str]: + """The ``sh:select`` query of every ``sh:sparql`` constraint on *shape*.""" + return [str(query) for node in g.objects(shape, SH.sparql) for query in g.objects(node, SH.select)] + + +def test_presence_implies_value_generates_sparql(): + """Presence-implies-value rules produce sh:sparql constraints on the NodeShape.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Document + sparql_nodes = list(g.objects(shape, SH.sparql)) + assert len(sparql_nodes) == 2, f"Expected 2 sh:sparql constraints, got {len(sparql_nodes)}" + + for node in sparql_nodes: + assert (node, RDF.type, SH.SPARQLConstraint) in g + selects = list(g.objects(node, SH.select)) + assert len(selects) == 1, "Each constraint must have exactly one sh:select" + query = str(selects[0]) + assert "$this" in query, "SPARQL must use $this pre-bound variable" + assert "?value = " in query, "presence-implies-value SPARQL must compare the target by value" + assert "FILTER" in query, "SPARQL must have a FILTER clause" + + +def test_presence_implies_value_single_uses_enum_iri(): + """A single equals_string target resolves to the enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + signature_query = [q for q in _sparql_queries(g, EX_PIV.Document) if str(EX_PIV.signature) in q] + assert len(signature_query) == 1, "Expected exactly one signature rule" + query = signature_query[0] + + assert str(EX_PIV.status) in query + assert f"<{EX_PIV.Published}>" in query, f"Expected Published IRI, got:\n{query}" + + +def test_presence_implies_value_set_uses_all_iris(): + """equals_string_in resolves every allowed value to its enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + review_query = [q for q in _sparql_queries(g, EX_PIV.Document) if str(EX_PIV.review_score) in q] + assert len(review_query) == 1, "Expected exactly one review_score rule" + query = review_query[0] + + assert f"<{EX_PIV.Reviewed}>" in query, f"Expected Reviewed IRI, got:\n{query}" + assert f"<{EX_PIV.Approved}>" in query, f"Expected Approved IRI, got:\n{query}" + + +def test_presence_implies_value_no_meaning_falls_back_to_literal(): + """When the target enum value lacks a meaning IRI, it is compared as a literal.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + queries = _sparql_queries(g, EX_PIV.Device) + assert len(queries) == 1 + query = queries[0] + assert '"Manual"' in query, f"No-meaning enum should use literal '\"Manual\"', got:\n{query}" + assert f"<{EX_PIV}Manual>" not in query, "Should not emit as IRI when meaning is absent" + + +def test_presence_implies_value_message_from_description(): + """Rule description is emitted as sh:message on the SPARQLConstraint.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + sparql_nodes = list(g.objects(EX_PIV.Document, SH.sparql)) + messages = [str(m) for node in sparql_nodes for m in g.objects(node, SH.message)] + + assert any("status must be Published" in m for m in messages), f"Expected message about Published, got: {messages}" + + +def test_presence_implies_value_sparql_syntax_valid(): + """Generated SPARQL for presence-implies-value rules must be syntactically valid.""" + from rdflib.plugins.sparql import prepareQuery + + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + for shape in (EX_PIV.Document, EX_PIV.Device): + for query in _sparql_queries(g, shape): + prepareQuery(query) + + +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param('ex:x a ex:Document ; ex:signature "s" ; ex:status ex:Published .', False, id="single-ok"), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "4.5"^^xsd:float ; ex:status ex:Reviewed .', False, id="set-ok" + ), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "3.0"^^xsd:float ; ex:status ex:Approved .', + False, + id="set-other-member-ok", + ), + pytest.param("ex:x a ex:Document ; ex:status ex:Draft .", False, id="unguarded"), + pytest.param('ex:x a ex:Document ; ex:signature "s" ; ex:status ex:Draft .', True, id="wrong-value"), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "4.5"^^xsd:float ; ex:status ex:Published .', + True, + id="not-in-set", + ), + pytest.param('ex:x a ex:Document ; ex:signature "s" .', True, id="target-absent"), + pytest.param('ex:x a ex:Device ; ex:manual_value 1.5 ; ex:mode "Manual" .', False, id="literal-ok"), + pytest.param('ex:x a ex:Device ; ex:manual_value 1.5 ; ex:mode "Auto" .', True, id="literal-wrong"), + ], +) +def test_presence_implies_value_pyshacl_end_to_end(instance: str, violates: bool): + """End-to-end: pyshacl passes conforming instances and flags violations.""" + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + instance + ) + conforms, focus_nodes = _validate_rules(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, data) + assert focus_nodes == ({EX_PIV.x} if violates else set()) + assert conforms is not violates + + +@pytest.mark.parametrize( + "instance,value", + [ + pytest.param('ex:signature "s" ; ex:status ex:Draft', EX_PIV.Draft, id="wrong-value"), + pytest.param('ex:signature "s"', EX_PIV.x, id="target-absent"), + ], +) +def test_presence_implies_value_result_names_path_and_value(instance, value): + """A result names the target property (``sh:resultPath``) and the + offending value (``sh:value``); when the target is absent, SHACL §5.3.2 + falls back to the focus node as the value.""" + data = f"@prefix ex: .\nex:x a ex:Document ; {instance} ." + _, results = _rule_results(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, data) + assert results == [(EX_PIV.x, EX_PIV.status, value)] + + +# =========================================================================== +# equals_string / equals_string_in compare strings +# =========================================================================== +# +# The metamodel defines both operators for slots of range string: "the slot +# must have range string and the value of the slot must equal the specified +# value". Enums, whose values are strings in instance data, and types with +# datatype xsd:string are treated alike. A rule applying them to a slot of +# any other range is skipped with a warning: its RDF values are typed literals +# ("3"^^xsd:integer, false) or IRIs that equal no string, so a translated +# constraint would report conforming data or never fire. On a string-valued +# slot the value is compared with the SPARQL "=" operator, which matches the +# RDF 1.1-identical plain and xsd:string literal forms alike, and an +# incomparable value counts as unequal rather than as an error. +# +# References: +# - SPARQL 1.1 §17.4.1.3 +# - SPARQL 1.1 §17.4.1.9 +# - RDF 1.1 Concepts §3.3 +# =========================================================================== + +_RULE_TARGET_RANGE_SCHEMA_YAML = """ +id: https://example.org/rule-target-range +name: rule_target_range +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/rule-target-range/ + xsd: http://www.w3.org/2001/XMLSchema# +imports: + - linkml:types +default_prefix: ex +default_range: string +types: + label_string: + typeof: string + token_string: + typeof: string + uri: xsd:token + flag_boolean: + typeof: boolean +enums: + Code: + permissible_values: + alpha: + beta: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Answer: + permissible_values: + "true": + meaning: ex:Yes + "false": + meaning: ex:No + Mixed: + permissible_values: + A: + meaning: ex:A + B: +classes: + Item: + class_uri: ex:Item + attributes: + id: + identifier: true + range: uriorcurie + Thing: + class_uri: ex:Thing + attributes: + guard: + multivalued: {guard_multivalued} + target: + range: {range} + multivalued: {multivalued} + rules: + - open_world: {open_world} + preconditions: + slot_conditions: + guard: {guard_condition} + postconditions: + slot_conditions: + target: {target_condition} + Sub: + is_a: Thing + class_uri: ex:Sub +""" + +EX_RTR = rdflib.Namespace("https://example.org/rule-target-range/") + +_RTR_PREFIXES = ( + "@prefix ex: .\n@prefix xsd: .\n" +) + + +def _rule_target_schema( + target_range: str, + value: str | list[str], + multivalued: bool = False, + *, + operator: str = "equals_string", + open_world: bool = False, + guard_multivalued: bool = False, + guard_extra: dict | None = None, + target_extra: dict | None = None, +) -> str: + """The rule-target schema: if ``guard`` is present, ``target`` (of *target_range*) must satisfy *operator*. + + *value* is the operand of *operator* (a list for ``equals_string_in``); + *guard_extra* and *target_extra* add operators to the two conditions. + ``Sub`` inherits the rule from ``Thing``. + """ + return _RULE_TARGET_RANGE_SCHEMA_YAML.format( + range=target_range, + multivalued=str(multivalued).lower(), + guard_multivalued=str(guard_multivalued).lower(), + open_world=str(open_world).lower(), + guard_condition=json.dumps({"value_presence": "PRESENT", **(guard_extra or {})}), + target_condition=json.dumps({operator: value, **(target_extra or {})}), + ) + + +@pytest.mark.parametrize( + "target_range,operator,value,conforming_term", + [ + pytest.param("integer", "equals_string", "3", "3", id="integer"), + pytest.param("integer", "equals_string_in", ["3", "4"], "3", id="integer-equals-string-in"), + pytest.param("float", "equals_string", "1.5", '"1.5"^^xsd:float', id="float"), + pytest.param("decimal", "equals_string", "1.5", "1.5", id="decimal"), + pytest.param("date", "equals_string", "2024-01-01", '"2024-01-01"^^xsd:date', id="date"), + pytest.param("uriorcurie", "equals_string", "ex:a", "ex:a", id="uriorcurie"), + pytest.param("token_string", "equals_string", "a", '"a"^^xsd:token', id="string-derived-xsd-token"), + pytest.param("Item", "equals_string", "ex:i1", "ex:i1 . ex:i1 a ex:Item", id="class"), + ], +) +def test_rule_equals_string_on_non_string_range_skipped(caplog, target_range, operator, value, conforming_term): + """equals_string on a slot that does not hold strings is skipped with a warning. + + The data holds a typed literal or an IRI, which equals no string: comparing + it with one would report every conforming instance. The conforming instance + must therefore pass, and the skip must be reported. + """ + schema = _rule_target_schema(target_range, value, operator=operator) + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + + assert _sparql_queries(g, EX_RTR.Thing) == [] + assert _sparql_queries(g, EX_RTR.Sub) == [] + assert any( + f"slot 'target', whose range '{target_range}' is neither an enum nor a type with datatype xsd:string or " + "xsd:boolean" in rec.message + for rec in caplog.records + ), caplog.text + conforms, focus_nodes = _validate_rules( + schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target {conforming_term} .' + ) + assert conforms and focus_nodes == set() + + +_STRING_TARGETS = [ + pytest.param("string", "x", '"x"', '"y"', id="string"), + pytest.param("label_string", "x", '"x"', '"y"', id="string-derived"), + pytest.param("curie", "ex:a", '"ex:a"', '"ex:b"', id="curie"), + pytest.param("Code", "alpha", '"alpha"', '"beta"', id="enum-literal"), + pytest.param("Color", "Red", "ex:Red", "ex:Blue", id="enum-meaning"), + pytest.param("Answer", "true", "ex:Yes", "ex:No", id="enum-meaning-named-true"), +] + + +@pytest.mark.parametrize("target_range,value,allowed,other", _STRING_TARGETS) +@pytest.mark.parametrize( + "body,violates", + [ + pytest.param('ex:guard "g" ; ex:target {allowed}', False, id="allowed"), + pytest.param('ex:guard "g" ; ex:target {allowed_xsd_string}', False, id="allowed-xsd-string"), + pytest.param('ex:guard "g" ; ex:target {other}', True, id="other"), + pytest.param('ex:guard "g" ; ex:target {allowed}, {other}', True, id="allowed-and-other"), + pytest.param('ex:guard "g"', True, id="target-absent"), + pytest.param("ex:target {other}", False, id="guard-absent"), + ], +) +@pytest.mark.parametrize("multivalued", [False, True]) +def test_rule_equals_string_on_string_range_compares_values( + target_range, value, allowed, other, body, violates, multivalued +): + """On a string-valued target, equals_string compares the value the range holds. + + A string type holds plain literals, an enum its meaning IRI or a plain + literal; the ``xsd:string``-typed form of a plain literal is the same RDF + 1.1 term and must also satisfy the rule. An enum value named ``true`` + with a meaning is an IRI like any other, not a boolean. + """ + allowed_xsd_string = f"{allowed}^^xsd:string" if allowed.startswith('"') else allowed + schema = _rule_target_schema(target_range, value, multivalued) + data = body.format(allowed=allowed, allowed_xsd_string=allowed_xsd_string, other=other) + + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + _, focus_nodes = _validate_rules(schema, f"{_RTR_PREFIXES}ex:x a ex:Thing ; {data} .") + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +_RULE_INSTANCES = [ + pytest.param({"guard": "g", "target": "{value}"}, True, True, id="allowed"), + pytest.param({"guard": "g", "target": "{other}"}, False, False, id="other"), + pytest.param({"guard": "g"}, False, True, id="target-absent"), + pytest.param({"target": "{other}"}, True, True, id="guard-absent"), +] +"""JSON instances of the rule-target schema, with their validity under closed- and open-world rules.""" + + +def _json_schema_and_shacl_verdicts( + schema: str, target_class: str, obj: dict, coerce_xsd_string: bool = False +) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL rules generated for *schema* accept *obj*. + + *obj* is validated as is by the generated JSON Schema, and as RDF loaded + through the generated JSON-LD context by the generated SHACL shapes (rule + constraints only). With *coerce_xsd_string* the context types the + ``target`` slot ``xsd:string``, as JSON-LD 1.1 type coercion permits, + which yields the RDF 1.1-identical typed form of a plain literal. + """ + import jsonschema + + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.jsonschemagen import JsonSchemaGenerator + + json_schema = json.loads(JsonSchemaGenerator(schema, top_class=target_class).serialize()) + json_schema_valid = jsonschema.Draft7Validator(json_schema).is_valid(obj) + + context = json.loads(ContextGenerator(schema).serialize())["@context"] + if coerce_xsd_string and context["target"].get("@type") is None: + context["target"]["@type"] = "xsd:string" + document = {"@context": context, "@type": target_class, **obj} + _, focus_nodes = _validate_rules(schema, rdflib.Graph().parse(data=json.dumps(document), format="json-ld")) + return json_schema_valid, focus_nodes == set() + + +@pytest.mark.parametrize( + "target_range,value,other", + [ + pytest.param("string", "x", "y", id="string"), + pytest.param("curie", "ex:a", "ex:b", id="curie"), + pytest.param("Code", "alpha", "beta", id="enum-literal"), + pytest.param("Color", "Red", "Blue", id="enum-meaning"), + ], +) +@pytest.mark.parametrize("instance,valid,_valid_open_world", _RULE_INSTANCES) +@pytest.mark.parametrize("coerce_xsd_string", [False, True]) +def test_rule_equals_string_agrees_with_json_schema( + target_range, value, other, instance, valid, _valid_open_world, coerce_xsd_string +): + """The SHACL rule and the JSON Schema rule decide the same instance alike. + + One JSON object is checked by both generated artifacts (see + :func:`_json_schema_and_shacl_verdicts`), with and without ``xsd:string`` + coercion of the target, which the test adds to the generated context. An + enum whose values all have a meaning in one namespace is mapped to those + IRIs by the context and stays untyped. + """ + obj = {key: text.format(value=value, other=other) for key, text in instance.items()} + verdicts = _json_schema_and_shacl_verdicts( + _rule_target_schema(target_range, value), "Thing", obj, coerce_xsd_string + ) + assert verdicts == (valid, valid) + + +@pytest.mark.parametrize( + "target_range,value,other", + [ + pytest.param("string", "x", "y", id="string"), + pytest.param("Color", "Red", "Blue", id="enum-meaning"), + ], +) +@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, 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. 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, 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) + + +@pytest.mark.parametrize("flag_range", ["boolean", "flag_boolean"]) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize("required", ["true", "1", "false", "0"]) +@pytest.mark.parametrize( + "flag,datatype", + [ + pytest.param("true", XSD.boolean, id="true"), + pytest.param("1", XSD.boolean, id="lexical-1"), + pytest.param("false", XSD.boolean, id="false"), + pytest.param("0", XSD.boolean, id="lexical-0"), + pytest.param("true", None, id="string-true"), + pytest.param(None, None, id="absent"), + ], +) +def test_rule_boolean_target_compared_by_value(flag_range, open_world, required, flag, datatype): + """equals_string on an xsd:boolean-typed slot (the boolean guard, for + "true") requires the boolean its value denotes in the lexical space of + xsd:boolean (XML Schema 1.1 Part 2 §3.3.2.2), whichever type the slot's + range derives it from. The flag is compared by value, so every lexical + form of a boolean satisfies the rule and a string "true" does not; an + open-world rule lets the flag be omitted. + + The data is built with unnormalised literals: rdflib would otherwise + rewrite ``"1"^^xsd:boolean`` to ``true`` while parsing, and a lexical + comparison would pass as well. + """ + schema = _rule_target_schema(flag_range, required, open_world=open_world) + required_value = required in ("true", "1") + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert len(queries) == 1 + assert f"?value = {str(required_value).lower()}" in queries[0], queries[0] + + data = rdflib.Graph() + data.add((EX_RTR.x, RDF.type, EX_RTR.Thing)) + data.add((EX_RTR.x, EX_RTR.guard, Literal("g"))) + if flag is not None: + data.add((EX_RTR.x, EX_RTR.target, Literal(flag, datatype=datatype, normalize=False))) + if flag is None: + violates = not open_world + else: + violates = datatype is None or (flag in ("true", "1")) != required_value + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +@pytest.mark.parametrize("value", ["yes", "True", "TRUE", " true", ""]) +def test_rule_boolean_target_non_lexical_value_skipped(caplog, value): + """A value outside the lexical space of xsd:boolean on a boolean slot equals + no boolean, so the rule is skipped with a warning. The lexical space is + exactly {true, false, 1, 0}: it is case-sensitive and has no whitespace.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_rule_target_schema("boolean", value)) + assert _sparql_queries(g, EX_RTR.Thing) == [] + assert any("not all xsd:boolean lexical forms" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "target,violates", + [ + pytest.param("ex:A", False, id="meaning-iri"), + pytest.param('"B"', False, id="literal"), + pytest.param('"B"^^xsd:string', False, id="literal-xsd-string"), + pytest.param('"A"', True, id="literal-of-value-with-meaning"), + pytest.param("ex:B", True, id="iri-of-value-without-meaning"), + pytest.param('"C"', True, id="other"), + ], +) +def test_rule_equals_string_in_mixes_meaning_iris_and_literals(target, violates): + """``equals_string_in`` over permissible values with and without a meaning + compares each as the term it is rendered as: its meaning IRI, or a literal.""" + schema = _rule_target_schema("Mixed", ["A", "B"], operator="equals_string_in") + _, focus_nodes = _validate_rules(schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target {target} .') + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +@pytest.mark.parametrize( + "target,violates", + [pytest.param('"x"', False, id="allowed"), pytest.param('"y"', True, id="other")], +) +def test_rule_multivalued_guard(target, violates): + """Several guard values trigger the rule once per focus node, like one.""" + schema = _rule_target_schema("string", "x", guard_multivalued=True) + data = f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g1", "g2", "g3" ; ex:target {target} .' + _, results = _rule_results(schema, data) + assert results == ([(EX_RTR.x, EX_RTR.target, rdflib.Literal("y"))] if violates else []) + + +def test_rule_query_yields_one_solution_per_focus_node(): + """Each solution of a SPARQL-based constraint is a validation result + (SHACL §5.3.2), so several guard values must not multiply the solutions. + pyshacl merges identical results; the query itself must not rely on it.""" + schema = _rule_target_schema("string", "x", guard_multivalued=True) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + data = rdflib.Graph().parse( + data=f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g1", "g2", "g3" ; ex:target "y" .', format="turtle" + ) + assert len(list(data.query(query, initBindings={"this": EX_RTR.x}))) == 1 + + +@pytest.mark.parametrize("dawg_literal_collation", [False, True]) +def test_rule_incomparable_value_is_a_violation(monkeypatch, dawg_literal_collation): + """A value no allowed value can be compared with equals none of them. + + SPARQL ``=`` raises a type error for literals it cannot compare + (RDFterm-equal, SPARQL 1.1 §17.4.1.7), and a FILTER error drops the row, so + a spec-conformant engine would miss the violation unless the comparison + turns the error into false. ``rdflib.DAWG_LITERAL_COLLATION`` switches + rdflib from its lenient comparison to that spec-conformant one. + """ + monkeypatch.setattr(rdflib, "DAWG_LITERAL_COLLATION", dawg_literal_collation) + schema = _rule_target_schema("string", "x") + data = f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target "x"^^ex:custom .' + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == {EX_RTR.x} + + exclusive = _exclusive_schema("Tag", "alpha", 1) + data = ( + '@prefix ex: .\nex:x a ex:Thing ; ex:tags "alpha", "beta"^^ex:custom .' + ) + _, focus_nodes = _validate_rules(exclusive, data) + assert focus_nodes == {EX_EXR.x} + + +def test_rule_value_not_a_permissible_value_warns(caplog): + """An equals_string value that is not a permissible value of the enum is + reported: no valid value of the slot can equal it, so the rule rejects + every instance it applies to.""" + schema = _rule_target_schema("Color", "Purple") + with caplog.at_level(logging.WARNING): + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + + assert len(queries) == 1 + assert any("'Purple', which is not a permissible value of enum 'Color'" in rec.message for rec in caplog.records) + _, focus_nodes = _validate_rules(schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target ex:Red .') + assert focus_nodes == {EX_RTR.x} + + +@pytest.mark.parametrize( + "value", + [ + pytest.param('a"b', id="quote"), + pytest.param("a\\b", id="backslash"), + pytest.param("line\nbreak", id="newline"), + pytest.param("carriage\rreturn", id="carriage-return"), + pytest.param("tab\there", id="tab"), + pytest.param("a\\u0022b", id="backslash-u"), + pytest.param("a\\U00000022b", id="backslash-capital-u"), + pytest.param("check ✓", id="non-ascii"), + pytest.param("", id="empty"), + ], +) +def test_rule_string_value_round_trips(value): + """An equals_string value of any content is matched exactly: the query + parses, the value itself satisfies the rule, and any other value does not. + + Codepoint escapes are replaced before a SPARQL query is parsed (SPARQL 1.1 + §19.2), so a backslash followed by ``u`` in the value must not reach the + query as an escape sequence. + """ + from rdflib.plugins.sparql import prepareQuery + + schema = _rule_target_schema("string", value) + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert len(queries) == 1 + prepareQuery(queries[0]) + + for target, violates in ((value, False), (value + "!", True)): + data = rdflib.Graph() + data.add((EX_RTR.x, RDF.type, EX_RTR.Thing)) + data.add((EX_RTR.x, EX_RTR.guard, Literal("g"))) + data.add((EX_RTR.x, EX_RTR.target, Literal(target))) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_RTR.x} if violates else set()), repr(target) + + +_EXCLUSIVE_RANGE_SCHEMA_YAML = """ +id: https://example.org/exclusive-range +name: exclusive_range +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/exclusive-range/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Tag: + permissible_values: + alpha: + beta: + gamma: +classes: + Thing: + class_uri: ex:Thing + attributes: + tags: + range: {range} + multivalued: true + rules: + - preconditions: + slot_conditions: + tags: {precondition} + postconditions: + slot_conditions: + tags: + maximum_cardinality: {max_card} +""" + +EX_EXR = rdflib.Namespace("https://example.org/exclusive-range/") + + +def _exclusive_schema(tag_range: str, value: str, max_card: int, *, has_member: bool = True) -> str: + """The exclusive-value schema: if *value* is one of the ``tags``, there are at most *max_card* of them. + + The precondition is ``has_member: {equals_string: value}``, or with + ``has_member=False`` the bare ``equals_string: value``. + """ + precondition = {"has_member": {"equals_string": value}} if has_member else {"equals_string": value} + return _EXCLUSIVE_RANGE_SCHEMA_YAML.format( + range=tag_range, precondition=json.dumps(precondition), max_card=max_card + ) + + +@pytest.mark.parametrize("has_member", [True, False]) +@pytest.mark.parametrize( + "max_card,values,violates", + [ + pytest.param(1, '"alpha", "beta"', True, id="max1-plain"), + pytest.param(1, '"alpha"^^xsd:string, "beta"^^xsd:string', True, id="max1-xsd-string"), + pytest.param(1, '"alpha"', False, id="max1-alone"), + pytest.param(1, '"beta", "gamma"', False, id="max1-absent"), + pytest.param(2, '"alpha", "beta", "gamma"', True, id="max2-plain"), + pytest.param(2, '"alpha"^^xsd:string, "beta", "gamma"', True, id="max2-xsd-string"), + pytest.param(2, '"alpha", "beta"', False, id="max2-within"), + ], +) +def test_exclusive_value_matches_value_not_term(has_member, max_card, values, violates): + """The exclusive value is matched by value, so its RDF 1.1-identical + ``xsd:string``-typed form triggers the rule as the plain literal does, and + a ``maximum_cardinality`` above 1 is enforced. The precondition + ``has_member: {equals_string: V}`` and the bare ``equals_string: V`` are + translated alike.""" + schema = _exclusive_schema("Tag", "alpha", max_card, has_member=has_member) + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + f"ex:x a ex:Thing ; ex:tags {values} ." + ) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_EXR.x} if violates else set()) + + +@pytest.mark.parametrize("max_card", [1, 2]) +def test_exclusive_value_result_names_path_and_value(max_card): + """A result names the slot. For ``maximum_cardinality: 1`` its value is + each value coexisting with the exclusive one; otherwise no value is + projected and SHACL §5.3.2 falls back to the focus node.""" + data = '@prefix ex: .\nex:x a ex:Thing ; ex:tags "alpha", "beta", "gamma" .' + _, results = _rule_results(_exclusive_schema("Tag", "alpha", max_card), data) + if max_card == 1: + assert sorted(results) == sorted( + [(EX_EXR.x, EX_EXR.tags, Literal("beta")), (EX_EXR.x, EX_EXR.tags, Literal("gamma"))] + ) + else: + assert results == [(EX_EXR.x, EX_EXR.tags, EX_EXR.x)] + + +@pytest.mark.parametrize("max_card", [1, 2]) +def test_exclusive_value_query_yields_distinct_solutions(max_card): + """The exclusive value in both RDF 1.1-identical forms binds twice; each + solution is a validation result (SHACL §5.3.2), so the query must not + multiply them.""" + (query,) = _sparql_queries(_parse_shacl(_exclusive_schema("Tag", "alpha", max_card)), EX_EXR.Thing) + data = rdflib.Graph().parse( + data="@prefix ex: .\n" + "@prefix xsd: .\n" + 'ex:x a ex:Thing ; ex:tags "alpha", "alpha"^^xsd:string, "beta" .', + format="turtle", + ) + assert len(list(data.query(query, initBindings={"this": EX_EXR.x}))) == 1 + + +def test_exclusive_value_bare_equals_string_warns(caplog): + """A bare equals_string precondition on a multivalued slot is translated as + "one of the values equals", with a warning: the specification applies a + slot constraint to all members of a collection, and has_member states the + intended reading explicitly.""" + with caplog.at_level(logging.WARNING): + assert _sparql_queries(_parse_shacl(_exclusive_schema("Tag", "alpha", 1, has_member=False)), EX_EXR.Thing) + assert any("has_member" in rec.message and "all members" in rec.message for rec in caplog.records), caplog.text + caplog.clear() + with caplog.at_level(logging.WARNING): + _parse_shacl(_exclusive_schema("Tag", "alpha", 1)) + assert not any("has_member" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "precondition", + [ + pytest.param({"has_member": {"equals_string": "alpha", "pattern": "^a"}}, id="inside-has_member"), + pytest.param({"has_member": {"equals_string": "alpha"}, "minimum_cardinality": 2}, id="beside-has_member"), + ], +) +def test_exclusive_value_has_member_extra_operator_skipped(caplog, precondition): + """has_member is exact too: an operator next to its equals_string, or next + to has_member itself, makes the rule untranslatable.""" + precondition = json.dumps(precondition) + schema = _EXCLUSIVE_RANGE_SCHEMA_YAML.format(range="Tag", precondition=precondition, max_card=1) + with caplog.at_level(logging.WARNING): + assert _sparql_queries(_parse_shacl(schema), EX_EXR.Thing) == [] + assert any("match none of the translated patterns" in rec.message for rec in caplog.records), caplog.text + + +def test_exclusive_value_on_non_string_range_skipped(caplog): + """equals_string on a slot that does not hold strings is skipped with a + warning in the exclusive-value pattern too: the integer ``3`` never equals + the string "3", so the constraint would never fire.""" + schema = _exclusive_schema("integer", "3", 1) + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + + assert _sparql_queries(g, EX_EXR.Thing) == [] + assert any("whose range 'integer' is neither an enum" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_non_operator_fields_are_the_metamodel_element_metadata(): + """The fields the operator accounting ignores are exactly the metamodel's + ``element`` slots that are not ``slot_expression`` slots.""" + from linkml_runtime.utils.formatutils import underscore + from linkml_runtime.utils.introspection import package_schemaview + + metamodel = package_schemaview("linkml_runtime.linkml_model.meta") + element_slots = {underscore(s) for s in metamodel.class_slots("element")} + operator_slots = {underscore(s) for s in metamodel.class_slots("slot_expression")} + + assert ShaclGenerator._NON_OPERATOR_FIELDS == element_slots - operator_slots + assert {"description", "annotations", "title"} <= ShaclGenerator._NON_OPERATOR_FIELDS + + +def test_rule_condition_metadata_does_not_block_translation(): + """Metadata on a condition does not change which instances satisfy it, + so it does not make an otherwise recognised rule untranslatable.""" + schema = _rule_target_schema( + "string", + "x", + guard_extra={"title": "Guard set"}, + target_extra={"description": "The target must then be x.", "comments": ["Metadata only."]}, + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + + +# =========================================================================== +# Rule inheritance +# +# A rule applies to "all members of this class" (metamodel `rules`), so a +# class shape carries the rules of its ancestors and mixins, each translated +# in the class's own context. +# =========================================================================== + +_RULE_INHERITANCE_SCHEMA_YAML = """ +id: https://example.org/rule-inheritance +name: rule_inheritance +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/rule-inheritance/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Shade: + permissible_values: + Red: + meaning: ex:ShadeRed +slots: + guard: {} + target: + range: Color + code: {} + label: {} +classes: + Base: + class_uri: ex:Base + slots: [guard, target] + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: Red + Child: + is_a: Base + class_uri: ex:Child + GrandChild: + is_a: Child + class_uri: ex:GrandChild + Shaded: + is_a: Base + class_uri: ex:Shaded + slot_usage: + target: + range: Shade + Counted: + is_a: Base + class_uri: ex:Counted + slot_usage: + target: + range: integer + Labelled: + mixin: true + class_uri: ex:Labelled + slots: [code, label] + rules: + - preconditions: + slot_conditions: + code: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: x + Mixed: + class_uri: ex:Mixed + mixins: [Labelled] + SameUri: + is_a: Base + class_uri: ex:Base +""" + +EX_RI = rdflib.Namespace("https://example.org/rule-inheritance/") + + +def test_rule_inherited_by_descendant_shapes(caplog): + """The shape of a class carries the rules of its ancestors and mixins, each + translated in the class's own context: a ``slot_usage`` that narrows the + target to another enum resolves the value there, and one that narrows it to + a non-string range skips the rule for that class only. A class sharing its + ancestor's ``class_uri`` shares its shape, which carries the rule once.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_RULE_INHERITANCE_SCHEMA_YAML) + + for cls in ("Base", "Child", "GrandChild", "Shaded", "Labelled", "Mixed"): + assert len(_sparql_queries(g, EX_RI[cls])) == 1, cls + assert f"<{EX_RI.ShadeRed}>" in _sparql_queries(g, EX_RI.Shaded)[0] + assert _sparql_queries(g, EX_RI.Counted) == [] + assert any("'Counted'" in rec.message and "is neither an enum" in rec.message for rec in caplog.records) + + +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param('ex:x a ex:Child ; ex:guard "g" ; ex:target ex:Red .', False, id="child-ok"), + pytest.param('ex:x a ex:Child ; ex:guard "g" ; ex:target ex:Blue .', True, id="child-violates"), + pytest.param('ex:x a ex:GrandChild ; ex:guard "g" ; ex:target ex:Blue .', True, id="grandchild-violates"), + pytest.param('ex:x a ex:Shaded ; ex:guard "g" ; ex:target ex:ShadeRed .', False, id="narrowed-ok"), + pytest.param('ex:x a ex:Shaded ; ex:guard "g" ; ex:target ex:Red .', True, id="narrowed-violates"), + pytest.param('ex:x a ex:Mixed ; ex:code "c" ; ex:label "x" .', False, id="mixin-ok"), + pytest.param('ex:x a ex:Mixed ; ex:code "c" ; ex:label "y" .', True, id="mixin-violates"), + ], +) +def test_rule_inherited_rule_enforced_on_descendant_instances(instance, violates): + """An instance typed only with a descendant class is held to the inherited rule.""" + data = f"@prefix ex: .\n{instance}" + _, focus_nodes = _validate_rules(_RULE_INHERITANCE_SCHEMA_YAML, data) + assert focus_nodes == ({EX_RI.x} if violates else set()) + + +def test_rule_shared_shape_reports_once(): + """Two classes sharing a ``class_uri`` share one shape, so an instance + violating the rule both inherit gets one result, not one per class.""" + data = '@prefix ex: .\nex:x a ex:Base ; ex:guard "g" ; ex:target ex:Blue .' + _, results = _rule_results(_RULE_INHERITANCE_SCHEMA_YAML, data) + assert results == [(EX_RI.x, EX_RI.target, EX_RI.Blue)] + + +# =========================================================================== +# Slot resolution and value rendering +# +# A rule's SPARQL body must query the same IRI that ``sh:path`` emits for the +# slot, and resolve the slot's range in the class: +# 1. a slot_usage `slot_uri` (or enum `range`) override applies to the rule; +# 2. an alias-form key (``my_slot`` for slot ``my slot``) names the slot; +# 3. a slot without a range takes the schema's `default_range`; +# 4. built-in type names resolve without importing linkml:types; +# 5. a condition on an unknown or identifier slot makes the rule untranslatable. +# =========================================================================== + + +_ENUM_NARROWING_SCHEMA_YAML = """ +id: https://example.org/enum-narrowing +name: enum_narrowing_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/enum-narrowing/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + BaseMode: + permissible_values: + Active: + meaning: ex:GLOBAL_Active + SceneMode: + permissible_values: + Active: + meaning: ex:LOCAL_Active + +slots: + activator: + range: string + slot_uri: ex:activator + mode: + range: BaseMode + slot_uri: ex:mode + +classes: + Scene: + class_uri: ex:Scene + slots: + - activator + - mode + slot_usage: + mode: + range: SceneMode + rules: + - description: If an activator is present the mode must be Active. + preconditions: + slot_conditions: + activator: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: Active +""" + +EX_EN = rdflib.Namespace("https://example.org/enum-narrowing/") + + +def test_rule_enum_range_narrowed_by_slot_usage(): + """A slot_usage range override to a class-specific enum must resolve the + value's meaning against the induced (narrowed) enum, not the base range.""" + queries = _sparql_queries(_parse_shacl(_ENUM_NARROWING_SCHEMA_YAML), EX_EN.Scene) + assert len(queries) == 1 + query = queries[0] + assert str(EX_EN.LOCAL_Active) in query, f"must resolve the narrowed enum meaning, got:\n{query}" + assert "GLOBAL_Active" not in query, f"must not resolve the base enum meaning, got:\n{query}" + + +_SLOT_RESOLUTION_SCHEMA_YAML = """ +id: https://example.org/slot-resolution +name: slot_resolution +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/slot-resolution/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + guard: {} + my slot: {} + target: {} +classes: + Thing: + class_uri: ex:Thing + slots: [guard, my slot, target] + slot_usage: + target: + slot_uri: ex:narrowed_target + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: x + - preconditions: + slot_conditions: + my_slot: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: x +""" + +EX_SR = rdflib.Namespace("https://example.org/slot-resolution/") + + +def test_rule_slot_iris_match_sh_path(): + """Every predicate a rule queries is one that ``sh:path`` emits: a + slot_usage ``slot_uri`` override and an alias-form key resolve to the + slot's own IRI rather than a fabricated one the data never uses.""" + g = _parse_shacl(_SLOT_RESOLUTION_SCHEMA_YAML) + + paths = {str(path) for path in g.objects(None, SH.path)} + queries = _sparql_queries(g, EX_SR.Thing) + assert len(queries) == 2 + for query in queries: + assert set(re.findall(r"<([^>]+)>", query)) <= paths, query + assert f"<{EX_SR.narrowed_target}>" in query, query + assert any(f"<{EX_SR.my_slot}>" in query for query in queries) + + _, focus_nodes = _validate_rules( + _SLOT_RESOLUTION_SCHEMA_YAML, + "@prefix ex: .\n" + 'ex:x a ex:Thing ; ex:my_slot "s" ; ex:narrowed_target "y" .', + ) + assert focus_nodes == {EX_SR.x} + + +_RANGE_RESOLUTION_SCHEMA_YAML = """ +id: https://example.org/range-resolution +name: range_resolution +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/range-resolution/ +imports: + - linkml:types +default_prefix: ex +default_range: {default_range} +slots: + guard: + range: string + target: {target_slot} +classes: + Thing: + class_uri: ex:Thing + slots: {class_slots} + slot_usage: {slot_usage} + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: "3" +""" + +EX_RR = rdflib.Namespace("https://example.org/range-resolution/") + + +@pytest.mark.parametrize( + "default_range,target_slot,class_slots,slot_usage,translated", + [ + pytest.param("string", "{}", "[guard, target]", "{}", True, id="default-range-string"), + pytest.param("integer", "{}", "[guard, target]", "{}", False, id="default-range-integer"), + pytest.param( + "string", + "{range: integer}", + "[guard, target]", + "{target: {range: string}}", + True, + id="slot-usage-to-string", + ), + pytest.param( + "string", "{}", "[guard, target]", "{target: {range: integer}}", False, id="slot-usage-to-integer" + ), + pytest.param("string", "{}", "[guard]", "{}", True, id="slot-not-on-class-default-string"), + pytest.param("integer", "{}", "[guard]", "{}", False, id="slot-not-on-class-default-integer"), + ], +) +def test_rule_target_range_resolved_in_class_context(default_range, target_slot, class_slots, slot_usage, translated): + """Whether the target holds strings is decided by its range in the class: + the schema's ``default_range`` for a slot without one, as refined by + ``slot_usage``, also for a slot the class does not declare.""" + schema = _RANGE_RESOLUTION_SCHEMA_YAML.format( + default_range=default_range, target_slot=target_slot, class_slots=class_slots, slot_usage=slot_usage + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_RR.Thing)) == (1 if translated else 0) + if translated: + prefix = "@prefix ex: .\n" + assert _validate_rules(schema, f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target "3" .')[1] == set() + assert _validate_rules(schema, f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target "4" .')[1] == {EX_RR.x} + + +EX_SINGLE = rdflib.Namespace("https://example.org/single-rule/") + + +def _single_rule_schema(rule: dict, attributes: dict | None = None, *, imports: bool = True) -> str: + """A schema whose class ``Thing`` has *attributes* and the one *rule* (JSON, which is YAML). + + By default the attributes are the string slots ``guard``, ``target`` and + ``other``, the boolean ``flag`` and the multivalued ``tags``. Without + *imports* the schema does not import ``linkml:types`` and has no + ``default_range``. + """ + schema = { + "id": "https://example.org/single-rule", + "name": "single_rule", + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": str(EX_SINGLE)}, + "default_prefix": "ex", + "classes": { + "Thing": { + "class_uri": "ex:Thing", + "attributes": attributes + or { + "guard": {"range": "string"}, + "target": {"range": "string"}, + "other": {"range": "string"}, + "flag": {"range": "boolean"}, + "tags": {"range": "string", "multivalued": True}, + }, + "rules": [rule], + } + }, + } + if imports: + schema["imports"] = ["linkml:types"] + schema["default_range"] = "string" + return json.dumps(schema) + + +def _rule(pre: dict, post: dict, **rule_fields) -> dict: + """A rule with the slot conditions *pre* and *post*.""" + return {"preconditions": {"slot_conditions": pre}, "postconditions": {"slot_conditions": post}, **rule_fields} + + +_GUARD_PRESENT = {"guard": {"value_presence": "PRESENT"}} + + +def _assert_skipped(caplog, schema: str, reason: str) -> None: + """Generating *schema* emits no rule constraint and warns that the rule was skipped for *reason*.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + assert _sparql_queries(g, EX_SINGLE.Thing) == [] + assert any("skipped, because" in rec.message and reason in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "target_range,value,allowed,other", + [ + pytest.param("boolean", "true", "true", "false", id="boolean-guard"), + pytest.param("string", "x", '"x"', '"y"', id="string"), + pytest.param("curie", "ex:a", '"ex:a"', '"ex:b"', id="curie"), + pytest.param(None, "x", '"x"', '"y"', id="no-range"), + ], +) +def test_rule_builtin_types_without_imports(target_range, value, allowed, other): + """In a schema that does not import ``linkml:types``, a built-in range name + resolves as the main slot loop resolves it, so the boolean guard and the + string comparison are both translated. A slot with no range at all (and + no ``default_range``) holds untyped values, compared as strings as the + JSON Schema generator compares them.""" + target = {} if target_range is None else {"range": target_range} + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": value}}), + {"guard": {"range": "string"}, "target": target}, + imports=False, + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_SINGLE.Thing)) == 1 + prefix = "@prefix ex: .\n" + for term, violates in ((allowed, False), (other, True)): + data = f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target {term} .' + assert _validate_rules(schema, data)[1] == ({EX_SINGLE.x} if violates else set()) + + +def test_rule_builtin_non_string_type_without_imports_skipped(caplog): + """A built-in non-string range without the ``linkml:types`` import is skipped like an imported one.""" + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": "3"}}), + {"guard": {"range": "string"}, "target": {"range": "integer"}}, + imports=False, + ) + _assert_skipped(caplog, schema, "is neither an enum nor a type with datatype xsd:string or xsd:boolean") + + +def test_curie_slot_without_imports_has_xsd_string_datatype(): + """A ``curie`` slot in a schema without the ``linkml:types`` import gets + ``sh:datatype xsd:string``, as with the import.""" + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": "ex:a"}}), + {"guard": {"range": "string"}, "target": {"range": "curie"}}, + imports=False, + ) + g = _parse_shacl(schema) + datatypes = { + o + for p in g.objects(EX_SINGLE.Thing, SH.property) + if (p, SH.path, EX_SINGLE.target) in g + for o in g.objects(p, SH.datatype) + } + assert datatypes == {XSD.string} + + +def test_rule_dynamic_enum_value_not_reported(caplog): + """An enum without static permissible values (a dynamic enum) can hold + values the schema does not list, so its equals_string value is not + reported as unknown.""" + + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "GO:0008150"}}))) + schema["enums"] = {"Process": {"reachable_from": {"source_ontology": "obo:go", "source_nodes": ["GO:0008150"]}}} + schema["classes"]["Thing"]["attributes"]["target"] = {"range": "Process"} + with caplog.at_level(logging.WARNING): + queries = _sparql_queries(_parse_shacl(json.dumps(schema)), EX_SINGLE.Thing) + assert len(queries) == 1 and '"GO:0008150"' in queries[0] + assert not any("permissible value" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_message_follows_default_language(): + """The rule description becomes ``sh:message``, tagged with the default + language like every other human-readable literal the generator emits.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}, description="The target must be x.") + g = _parse_shacl(_single_rule_schema(rule), default_language="en") + (node,) = g.objects(EX_SINGLE.Thing, SH.sparql) + assert set(g.objects(node, SH.message)) == {Literal("The target must be x.", lang="en")} + + +@pytest.mark.parametrize( + "descriptions,constraints", + [ + pytest.param(["Same.", "Same."], 1, id="identical-rules"), + pytest.param(["First.", "Second."], 2, id="different-messages"), + ], +) +def test_rule_identical_constraints_emitted_once(descriptions, constraints): + """A shape carries an identical constraint (query and message) once; rules + that differ in their message are kept apart.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}))) + rule = schema["classes"]["Thing"]["rules"][0] + schema["classes"]["Thing"]["rules"] = [{**rule, "description": text} for text in descriptions] + assert len(list(_parse_shacl(json.dumps(schema)).objects(EX_SINGLE.Thing, SH.sparql))) == constraints + + +def test_rule_problem_reported_once_for_inheriting_classes(caplog): + """A problem with a rule is reported once, naming the declaring class and + the rule's position, however many classes inherit it; and a skipped rule + with elseconditions is not also reported as partially emitted.""" + + schema = json.loads( + _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, elseconditions={"slot_conditions": {}}) + ) + ) + for name in ("C1", "C2", "C3"): + schema["classes"][name] = {"is_a": "Thing", "class_uri": f"ex:{name}"} + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1, messages + assert "skipped, because" in messages[0] and "(in the shapes of 'Thing', 'C1', 'C2', 'C3')" in messages[0] + assert not any("elseconditions" in rec.message for rec in caplog.records), caplog.text + + +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["classes"] = {"Sub": {"is_a": "Thing", "class_uri": "ex:Sub"}, **schema["classes"]} + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1, messages + assert "'Sub'" in messages[0] and "'Thing'" in messages[0], messages[0] + + +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["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, + {"color": {"equals_string": "Purple"}}, + description="Third", + elseconditions={"slot_conditions": {"color": {"equals_string": "Red"}}}, + ), + ] + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "of class 'Thing'" in rec.message] + assert len([m for m in messages if m.startswith("Rule 1 of class 'Thing' (First): skipped")]) == 1, messages + assert len([m for m in messages if m.startswith("Rule 2 of class 'Thing' (Second): skipped")]) == 1, messages + third = [m for m in messages if m.startswith("Rule 3 of class 'Thing' (Third)")] + assert len(third) == 2 and any("elseconditions" in m for m in third), messages + assert any("'Purple', which is not a permissible value" in m for m in third), messages + + +def test_rule_first_unknown_slot_is_the_reported_problem(caplog): + """A rule is skipped at its first untranslatable condition: with an + unknown guard and an unknown target, only the guard is reported.""" + schema = _single_rule_schema( + _rule({"no_such_guard": {"value_presence": "PRESENT"}}, {"no_such_target": {"equals_string": "x"}}) + ) + 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 "no_such_guard" in messages[0], messages + + +def test_exclusive_value_bare_equals_string_on_single_valued_slot_does_not_warn(caplog): + """On a single-valued slot, "the value equals V" and "one of the values + equals V" coincide, so the bare form is translated without a warning.""" + schema = _single_rule_schema(_rule({"target": {"equals_string": "x"}}, {"target": {"maximum_cardinality": 1}})) + with caplog.at_level(logging.WARNING): + assert len(_sparql_queries(_parse_shacl(schema), EX_SINGLE.Thing)) == 1 + assert not any("has_member" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_message_follows_rule_language(): + """A rule's own ``in_language`` takes precedence over the default language.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}, description="Das Ziel muss x sein.") + rule["in_language"] = "de" + g = _parse_shacl(_single_rule_schema(rule), default_language="en") + (node,) = g.objects(EX_SINGLE.Thing, SH.sparql) + assert set(g.objects(node, SH.message)) == {Literal("Das Ziel muss x sein.", lang="de")} + + +@pytest.mark.parametrize( + "rule", + [ + pytest.param( + _rule({"no_such_slot": {"value_presence": "PRESENT"}}, {"flag": {"equals_string": "true"}}), + id="boolean-guard-unknown-guard", + ), + pytest.param( + _rule({"no_such_slot": {"value_presence": "PRESENT"}}, {"target": {"equals_string": "x"}}), + id="presence-implies-value-unknown-guard", + ), + pytest.param( + _rule(_GUARD_PRESENT, {"no_such_slot": {"equals_string": "x"}}), + id="presence-implies-value-unknown-target", + ), + pytest.param( + _rule({"no_such_slot": {"equals_string": "x"}}, {"no_such_slot": {"maximum_cardinality": 1}}), + id="exclusive-value-unknown-slot", + ), + ], +) +def test_rule_unknown_slot_key_skipped(caplog, rule): + """A rule whose condition names a slot that does not exist is skipped: + a fabricated predicate would make a constraint that never fires (unknown + guard) or always fires (unknown target).""" + _assert_skipped(caplog, _single_rule_schema(rule), "'no_such_slot', which is not a slot") + + +@pytest.mark.parametrize( + "rule", + [ + pytest.param(_rule({"id": {"value_presence": "PRESENT"}}, {"target": {"equals_string": "x"}}), id="guard"), + pytest.param(_rule(_GUARD_PRESENT, {"id": {"equals_string": "x"}}), id="target"), + ], +) +def test_rule_identifier_slot_skipped(caplog, rule): + """The identifier is the node's IRI, not a property arc, so a condition on + it can never match a triple and the rule is skipped.""" + attributes = {"id": {"identifier": True, "range": "uriorcurie"}, "guard": {}, "target": {}} + _assert_skipped(caplog, _single_rule_schema(rule, attributes), "identifier slot 'id'") + + +_ESCAPING_SCHEMA_YAML = """ +id: https://example.org/escaping +name: escaping_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/escaping/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +slots: + trigger: + range: string + slot_uri: ex:trigger + label: + range: string + slot_uri: ex:label + +classes: + Item: + class_uri: ex:Item + slots: + - trigger + - label + rules: + - description: If a trigger is present the label must equal the quoted marker. + preconditions: + slot_conditions: + trigger: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: 'a"b\\\\c' +""" + +EX_ESC = rdflib.Namespace("https://example.org/escaping/") + + +def test_rule_equals_string_special_chars_escaped(): + """An equals_string value with a quote and backslash must be escaped so the + generated SPARQL stays syntactically valid (no injection / broken query).""" + from rdflib.plugins.sparql import prepareQuery + + queries = _sparql_queries(_parse_shacl(_ESCAPING_SCHEMA_YAML), EX_ESC.Item) + assert len(queries) == 1 + query = queries[0] + + # Would raise ParseException on the unescaped `... = "a"b\c"` form. + prepareQuery(query) + assert '\\"' in query, f"double quote must be escaped, got:\n{query}" + assert "\\\\" in query, f"backslash must be escaped, got:\n{query}" + + +# =========================================================================== +# Operator exactness +# +# A rule is translated only when its conditions set exactly the operators a +# pattern translates. Each rule below would match a pattern if the converter +# dropped the extra operator, which would widen the precondition (false +# positives) or weaken the postcondition (false negatives). +# =========================================================================== + + +@pytest.mark.parametrize( + "extra,reason", + [ + pytest.param({"pattern": "^x"}, "pattern", id="pattern"), + 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({"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({"equals_number": 3}, "equals_number", id="equals_number"), + pytest.param({"any_of": [{"equals_string": "x"}]}, "any_of", id="slot-any_of"), + ], +) +@pytest.mark.parametrize("condition", ["guard", "target"]) +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, reason) + + +@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"]) +@pytest.mark.parametrize("side", ["preconditions", "postconditions"]) +def test_rule_expression_level_operator_skipped(caplog, side, operator): + """An expression-level boolean operator on either side cannot be honoured + by any pattern; dropping it would widen or weaken the rule.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}) + rule[side][operator] = [{"slot_conditions": {"other": {"equals_string": "y"}}}] + _assert_skipped(caplog, _single_rule_schema(rule), f"its {side} use {operator}") + + +@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): + """The exclusive-value pattern is exact too: a precondition mixing + equals_string with an unsupported operator is skipped.""" + schema = _single_rule_schema( + _rule({"tags": {"equals_string": "x", "pattern": "^x"}}, {"tags": {"maximum_cardinality": 1}}) + ) + _assert_skipped(caplog, schema, "match none of the translated patterns") + + +_STRING_TRUE_SCHEMA_YAML = """ +id: https://example.org/string-true +name: string_true +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/string-true/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + opt: + slot_uri: ex:opt + status: + range: string + slot_uri: ex:status +classes: + Conf: + class_uri: ex:Conf + slots: [opt, status] + rules: + - description: If opt is present, status must be the string "true". + preconditions: + slot_conditions: + opt: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "true" +""" + + +def test_rule_equals_true_on_string_slot_uses_piv(): + """equals_string "true" on a NON-boolean slot is compared as the string + "true", not as the boolean of the boolean guard.""" + queries = _sparql_queries(_parse_shacl(_STRING_TRUE_SCHEMA_YAML), URIRef("https://example.org/string-true/Conf")) + assert len(queries) == 1 + assert '?value = "true"' in queries[0], f"string-range 'true' must be a string comparison, got:\n{queries[0]}" + assert "?value = true" not in queries[0], "the boolean guard must not apply to a string slot" + + +@pytest.mark.parametrize( + "status,violates", + [ + pytest.param('"true"', False, id="plain"), + pytest.param('"true"^^xsd:string', False, id="xsd-string"), + pytest.param('"other"', True, id="other"), + ], +) +def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): + """status "true" (string) satisfies the rule in either RDF 1.1-identical form.""" + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + f'ex:x a ex:Conf ; ex:opt "x" ; ex:status {status} .' + ) + _, 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) + + +_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 + + +@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": {"pattern": "^m"}}, _REQUIRE_NOTE), + "its precondition on slot 'mode' uses pattern", + id="pre-op", + ), + 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, "pattern": "^n"}}), + "its postcondition on slot 'note' uses pattern, 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, {**_REQUIRE_NOTE, "level": {"required": True}}), + "its postconditions constrain more than one slot", + id="two-postconditions", + ), + 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": {"pattern": "^1"}}}), + "its has_member on slot 'levels' uses pattern", + 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={"pattern": "^1"})}, _REQUIRE_NOTE), + "its inner condition on slot 'depth' uses pattern", + 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 + + +@pytest.mark.parametrize( + "schema,reason", + [ + pytest.param( + _compose_schema( + _rule({"part": _inner(kind={"equals_string": "Bogus"}), "mode": {"pattern": "^m"}}, _REQUIRE_NOTE) + ), + "its precondition on slot 'mode' uses pattern", + id="composed", + ), + pytest.param( + _single_rule_schema( + _rule({"levels": {"equals_string": "3"}}, {"levels": {"maximum_cardinality": 1}}), + {"levels": {"range": "integer", "multivalued": True}}, + ), + "whose range 'integer' is neither an enum", + id="exclusive-value", + ), + ], +) +def test_rule_skip_replaces_problems_noted_while_translating(caplog, schema, reason): + """A rule skipped part-way through its translation reports only why it was + skipped: a problem noted earlier (a value no enum permits, the reading of a + bare equals_string) describes a constraint that is not emitted.""" + 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()) diff --git a/tests/linkml/test_validator/test_shacl_validation_plugin.py b/tests/linkml/test_validator/test_shacl_validation_plugin.py index 19d7f5d9be..ba912166b0 100644 --- a/tests/linkml/test_validator/test_shacl_validation_plugin.py +++ b/tests/linkml/test_validator/test_shacl_validation_plugin.py @@ -11,6 +11,9 @@ # not re-exported, so that pyshacl stays an optional dependency. from linkml.validator.plugins.shacl_validation_plugin import ShaclValidationPlugin from linkml.validator.report import Severity +from linkml.validator.validation_context import ValidationContext +from linkml_runtime.linkml_model import SchemaDefinition +from linkml_runtime.loaders import yaml_loader def test_conforming_instance_yields_no_results(validation_context): @@ -87,3 +90,88 @@ def test_generated_shapes_are_cached(validation_context): list(plugin.process({"id": "P:1", "name": "One"}, validation_context)) assert len(plugin._loaded_graphs) == 1 + + +_RULES_SCHEMA_YAML = """ +id: https://example.org/plugin-rules +name: plugin_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/plugin-rules/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Code: + permissible_values: + alpha: + beta: +classes: + Base: + attributes: + id: + identifier: true + guard: {} + label: {} + color: + range: Color + code: + range: Code + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: x + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + color: + equals_string: Red + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + code: + equals_string_in: [alpha] + Child: + is_a: Base +""" + + +@pytest.mark.parametrize("target_class", ["Base", "Child"]) +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param({"guard": "g", "label": "x", "color": "Red", "code": "alpha"}, False, id="conforming"), + pytest.param({"guard": "g", "label": "y", "color": "Red", "code": "alpha"}, True, id="string"), + pytest.param({"guard": "g", "label": "x", "color": "Blue", "code": "alpha"}, True, id="enum-meaning"), + pytest.param({"guard": "g", "label": "x", "color": "Red", "code": "beta"}, True, id="enum-literal"), + pytest.param({"guard": "g", "color": "Red", "code": "alpha"}, True, id="target-absent"), + pytest.param({"label": "y", "color": "Blue", "code": "beta"}, False, id="unguarded"), + ], +) +def test_rule_violation_is_reported(target_class, instance, violates): + """Rules translated to ``sh:sparql`` are enforced through the plugin, whose + data graph comes from the LinkML RDF dumper, on instances of the declaring + class and of its subclasses alike.""" + context = ValidationContext(yaml_loader.loads(_RULES_SCHEMA_YAML, SchemaDefinition), target_class) + results = list(ShaclValidationPlugin().process({"id": "ex:x", **instance}, context)) + + assert all(result.severity is Severity.ERROR for result in results) + assert any("SPARQLConstraintComponent" in result.message for result in results) is violates