diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index 6ca5255a4f..56eb0c515e 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -185,6 +185,9 @@ constrains each of its values in the same way, with its conditions on the slots of the value. For a slot inlined as a dict these are the values of the dict, except in its compact forms ``{id: value}`` and ``{id: null}``, which the expression can't reach. +A multivalued slot's `has_member `_ becomes +``contains``, and the slot is required, because a slot without a value has no +member; on a slot that isn't multivalued it is ignored, with a warning. The SHACL generator reads them the same way. Inlining diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index cd5c3565db..4c5031cc35 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -123,7 +123,9 @@ Each entry of ``slot_conditions`` gives an ``meaning`` where they have one; * ``range`` gives the same class, type or enum constraint as a slot's range; * ``range_expression`` gives an ``sh:node``, as described under - `Range Expressions`_. + `Range Expressions`_; +* ``has_member`` gives an ``sh:qualifiedValueShape``, as described under + `Members`_. A shape may have at most one value of ``sh:minInclusive``, ``sh:maxInclusive`` or ``sh:in``, and of ``sh:pattern``, whose component also takes ``sh:flags`` @@ -173,7 +175,7 @@ slot's range. An operator whose members use anything else is skipped as a whole and logged as a warning, because leaving out one member would change what the operator -admits. That covers, for example, ``has_member``, ``equals_expression`` or a +admits. That covers, for example, ``all_members``, ``equals_expression`` or a slot-level ``any_of`` inside a slot condition, a condition on a name that is not a slot, a condition on the identifier slot (the node's IRI rather than a property), and ``equals_string`` on a slot whose range does not hold strings. @@ -237,6 +239,35 @@ graph, as ``sh:class`` is. The JSON Schema generator can't check it, because there the value is just an identifier. +Members +^^^^^^^ + +A multivalued slot's `has_member `__ asks +for at least one value that satisfies an expression. It becomes an +``sh:qualifiedValueShape`` with ``sh:qualifiedMinCount 1`` (`SHACL §4.7.3 +`__), so +the other values need only satisfy the slot's own constraints. The member +expression constrains one value: its value operators, ``range`` and +``range_expression`` apply. + +.. code-block:: yaml + + slots: + tags: + range: string + multivalued: true + has_member: + equals_string: reviewed + +This accepts ``[reviewed, pending]`` and rejects ``[pending]``. A slot without a +value, absent or empty, has no member, so it fails ``has_member``, as the JSON +Schema generator also reads it. In a slot condition of a class-level expression, +``has_member`` therefore decides whether the slot may be absent, and the member +takes the form of the condition. Several conditions with ``has_member`` may be +satisfied by the same value. A ``has_member`` on a slot that isn't multivalued, +or one that uses anything else, is skipped and logged as a warning. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index 052b01ed21..4e6f901450 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -70,7 +70,7 @@ At the class level, each member of `any_of`, `all_of`, `exactly_one_of` and `non A slot condition needs a meaning when its slot is absent. The JSON Schema and SHACL generators read it as an SQL `CHECK` constraint reads a condition on a null value: an instance is invalid only when an expression is definitely false. -- A condition that decides whether its slot may be absent is true or false as usual. Such a condition sets `value_presence: PRESENT` or `ABSENT`, `required: true`, a minimum or exact cardinality of at least 1, or a maximum or exact cardinality of 0. +- A condition that decides whether its slot may be absent is true or false as usual. Such a condition sets `value_presence: PRESENT` or `ABSENT`, `required: true`, a minimum or exact cardinality of at least 1, `has_member`, or a maximum or exact cardinality of 0. - Any other condition is *unknown* when its slot is absent. - `any_of`, `all_of` and `none_of` combine these as "or", "and" and "not". An unknown member doesn't make `any_of` true, nor `none_of` false. - `exactly_one_of` holds when exactly one member is definitely true. @@ -94,6 +94,8 @@ A `Sample` without `status` is valid: the condition is unknown, so `none_of` isn A slot's [range_expression](https://w3id.org/linkml/range_expression) constrains each value of the slot in the same way: its conditions constrain the slots of the value, as induced for the slot's range class. In a slot condition, a `range_expression` is part of the condition, so under `none_of` it must hold definitely. +A slot's [has_member](https://w3id.org/linkml/has_member) asks for at least one value satisfying its expression. A slot without a value, absent or empty, has no member, so it fails `has_member`. + [Rules](#rules) are read differently: their preconditions require their slots, and so do their postconditions unless the rule is `open_world`. ### Unions as ranges diff --git a/packages/linkml/src/linkml/generators/common/class_expression.py b/packages/linkml/src/linkml/generators/common/class_expression.py index ba4bd04860..89693c5c9d 100644 --- a/packages/linkml/src/linkml/generators/common/class_expression.py +++ b/packages/linkml/src/linkml/generators/common/class_expression.py @@ -43,10 +43,10 @@ def states_presence(condition: SlotDefinition) -> bool: Such a condition is definitely true or false for an absent slot: ``value_presence: PRESENT`` or ``ABSENT``; ``required: true``, unless ``value_presence`` overrides it; a minimum or exact cardinality of at least - 1, which an absent slot fails; and a maximum or exact cardinality of 0, - which it satisfies. Other bounds, ``required: false`` and ``UNCOMMITTED`` - leave absence open, so adding one never changes the verdict on an absent - slot. + 1, and ``has_member``, which asks for at least one value, all of which an + absent slot fails; and a maximum or exact cardinality of 0, which it + satisfies. Other bounds, ``required: false`` and ``UNCOMMITTED`` leave + absence open, so adding one never changes the verdict on an absent slot. >>> states_presence(SlotDefinition("label", equals_string="A")) False @@ -58,12 +58,16 @@ def states_presence(condition: SlotDefinition) -> bool: True >>> states_presence(SlotDefinition("tags", minimum_cardinality=1)) True + >>> states_presence(SlotDefinition("tags", has_member={"equals_string": "A"})) + True """ if condition.value_presence is not None: if condition.value_presence in (_PRESENT, _ABSENT): return True elif condition.required: return True + if condition.has_member is not None: + return True lower = (condition.minimum_cardinality, condition.exact_cardinality) upper = (condition.maximum_cardinality, condition.exact_cardinality) return any(bound is not None and bound >= 1 for bound in lower) or 0 in upper @@ -72,9 +76,13 @@ def states_presence(condition: SlotDefinition) -> bool: def value_bounds(condition: SlotDefinition, definite: bool) -> tuple[int, int | None]: """The least and the greatest number of values *condition* allows its slot. - ``value_presence`` takes precedence over ``required``. In the "definitely - true" form (*definite*), a condition that doesn't state presence also - requires the slot. The greatest number is ``None`` when unbounded. + ``value_presence`` takes precedence over ``required``, and ``has_member`` + asks for at least one value. In the "definitely true" form (*definite*), a + condition that doesn't state presence also requires the slot. The greatest + number is ``None`` when unbounded. + + >>> value_bounds(SlotDefinition("tags", has_member={"equals_string": "A"}), definite=False) + (1, None) """ lower, upper = [0], [] if condition.value_presence is not None: @@ -84,6 +92,8 @@ def value_bounds(condition: SlotDefinition, definite: bool) -> tuple[int, int | upper.append(0) elif condition.required: lower.append(1) + if condition.has_member is not None: + lower.append(1) if definite and not states_presence(condition): lower.append(1) for bound, target in ( diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 98ed281927..0ae82ff600 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -691,7 +691,8 @@ def get_subschema_for_class_expression( Each slot condition constrains the slot as induced for *cls*, so ``slot_usage`` applies. Its values must satisfy its value operators, its ``range`` and its ``range_expression``, the latter in the form of - *expr*; the number of values must lie within + *expr*, and one of them its ``has_member``; the number of values must + lie within :func:`~linkml.generators.common.class_expression.value_bounds`, where an empty list or dict counts as no value. *definite* selects the form, as in :meth:`get_subschema_for_class_operator`. *cls* is ``None`` for values @@ -705,27 +706,26 @@ def get_subschema_for_class_expression( prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) - value_condition = condition - if condition.range_expression is not None: - # translated below, in the form of *expr* - value_condition = copy(condition) - value_condition.range_expression = None - values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) - if condition.range_expression is not None: - expression = self.get_subschema_for_range_expression( - condition.range or slot.range, condition.range_expression, definite - ) - values = JsonSchema({"allOf": [values, expression]}) if values else expression + values = self._value_subschema(slot, condition, definite) lower, upper = value_bounds(condition, definite) + member = None + if condition.has_member is not None: + if slot.multivalued: + member = self.get_subschema_for_member(slot, condition.has_member, definite) + else: + self._warn_has_member_on_single_valued(slot) if slot.multivalued and self._inlined_as_dict(slot): # the values of a slot inlined as a dict are the values of the dict prop = JsonSchema({"type": "object", "additionalProperties": values}) prop.add_keyword("minProperties", lower or None) prop.add_keyword("maxProperties", upper) + if member is not None: + prop.setdefault("allOf", []).append(self._some_dict_value(member)) elif slot.multivalued: prop = JsonSchema.array_of(values, include_null=False, required=False) prop.add_keyword("minItems", lower or None) prop.add_keyword("maxItems", upper) + prop.add_keyword("contains", member) else: prop = values if condition.range is not None: @@ -779,6 +779,69 @@ def _inlined_as_dict(self, slot: SlotDefinition) -> bool: and get_range_associated_slots(self.schemaview, slot.range)[0] is not None ) + def _value_subschema( + self, + slot: SlotDefinition | AnonymousSlotExpression, + condition: SlotDefinition | AnonymousSlotExpression, + definite: bool, + ) -> JsonSchema: + """The subschema each value of *slot* must satisfy under the value operators of *condition*. + + These include its ``range_expression``, in the form *definite*, but not + its ``range``, which depends on how the values of *slot* are represented. + """ + value_condition = condition + if condition.range_expression is not None: + # translated below, in the form *definite* + value_condition = copy(condition) + value_condition.range_expression = None + values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) + if condition.range_expression is not None: + expression = self.get_subschema_for_range_expression( + condition.range or slot.range, condition.range_expression, definite + ) + values = JsonSchema({"allOf": [values, expression]}) if values else expression + return values + + def get_subschema_for_member( + self, slot: SlotDefinition | AnonymousSlotExpression, member: AnonymousSlotExpression, definite: bool + ) -> JsonSchema: + """The subschema a value of multivalued *slot* satisfies as a member of its ``has_member`` *member*. + + A member is one value, which must satisfy the value operators, the + ``range`` and the ``range_expression`` of *member*, the latter in the + form *definite*. + """ + conjuncts = [self._value_subschema(slot, member, definite)] + if member.range is not None: + conjuncts.append(self._value_type_subschema(slot, member.range)) + conjuncts = [conjunct for conjunct in conjuncts if conjunct] + if len(conjuncts) == 1: + return conjuncts[0] + return JsonSchema({"allOf": conjuncts} if conjuncts else {}) + + def _value_type_subschema(self, slot: SlotDefinition | AnonymousSlotExpression, value_range: str) -> JsonSchema: + """The subschema of one value of range *value_range* as multivalued *slot* represents its values.""" + collection = self.get_subschema_for_slot( + AnonymousSlotExpression( + range=value_range, multivalued=True, inlined=slot.inlined, inlined_as_list=slot.inlined_as_list + ), + include_null=False, + ) + return collection["additionalProperties"] if collection.is_object else collection["items"] + + @staticmethod + def _warn_has_member_on_single_valued(slot: SlotDefinition | AnonymousSlotExpression) -> None: + """Warn that the ``has_member`` of *slot* is ignored: a member is a value of a multivalued slot.""" + logger.warning( + "Slot %r: has_member is not translated to JSON Schema, because the slot is not multivalued.", slot.name + ) + + @staticmethod + def _some_dict_value(member: JsonSchema) -> JsonSchema: + """The subschema of a dict with a value satisfying *member*: not every value fails it.""" + return JsonSchema({"not": {"additionalProperties": {"not": member}}}) + def _class_expression_slot(self, cls: ClassDefinition | None, slot_name: str) -> SlotDefinition | None: """The slot a condition of a class expression of *cls* names, as induced for *cls*, if any.""" sv = self.schemaview @@ -1105,19 +1168,26 @@ def get_subschema_for_slot( if prop.is_array: all_element_constraints = self.get_value_constraints_for_slot(slot.all_members) - any_element_constraints = self.get_value_constraints_for_slot(slot.has_member) prop.add_keyword("minItems", slot.minimum_cardinality) prop.add_keyword("maxItems", slot.maximum_cardinality) prop["items"].update(own_constraints) prop["items"].update(all_element_constraints) - if any_element_constraints: - prop["contains"] = any_element_constraints + if slot.has_member is not None: + # At least one value, so not null. + prop["type"] = "array" + prop["contains"] = self.get_subschema_for_member(slot, slot.has_member, definite=False) elif inlined_as_dict: # The values of the slot are the values of the dict. if own_constraints: prop["additionalProperties"] = JsonSchema({"allOf": [prop["additionalProperties"], own_constraints]}) + if slot.has_member is not None: + member = self.get_subschema_for_member(slot, slot.has_member, definite=False) + prop.setdefault("allOf", []).append(self._some_dict_value(member)) else: prop.update(own_constraints) + if slot.has_member is not None and not omit_type: + # with omit_type, the caller translates has_member for the slot the condition names + self._warn_has_member_on_single_valued(slot) if prop.is_object: prop.add_keyword("minProperties", slot.minimum_cardinality) @@ -1177,7 +1247,11 @@ def handle_class_slot(self, subschema: JsonSchema, cls: ClassDefinition, slot: S slot = self.before_generate_class_slot(slot, cls, self.schemaview) class_id_slot = self.schemaview.get_identifier_slot(cls.name, use_key=True) value_required = ( - slot.required or slot == class_id_slot or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT) + slot.required + or slot == class_id_slot + or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT) + # a member is a value + or (slot.multivalued and slot.has_member is not None) ) value_disallowed = slot.value_presence == PresenceEnum(PresenceEnum.ABSENT) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 11e670e5c5..4149bd07c5 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -224,7 +224,7 @@ def as_graph(self) -> Graph: self._class_expressions_added: set[tuple[URIRef, str, str]] = set() self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} - self._range_expression_problems: dict[tuple[str, str], list[str]] = {} + self._slot_expression_problems: dict[tuple[str, str, str], list[str]] = {} for c in sv.all_classes(imports=not self.exclude_imports).values(): def shape_pv(p, v): @@ -409,6 +409,8 @@ def branch_pv(p, v): prop_pv(SH.pattern, Literal(s.pattern)) if s.range_expression is not None: self._add_range_expression(g, prop_pv, c, s, s.range, s.range_expression) + if s.has_member is not None: + self._add_member(g, prop_pv, c, s) if s.annotations and self.include_annotations: self._add_annotations(prop_pv, s) @@ -423,7 +425,7 @@ def branch_pv(p, v): self._add_rules(g, class_uri_with_suffix, c) self._report_class_expression_problems() - self._report_range_expression_problems() + self._report_slot_expression_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" @@ -458,7 +460,9 @@ def branch_pv(p, v): "range_expression", } ) - _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS + # has_member, which asks for at least one value satisfying a value expression, + # does both. + _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS | {"has_member"} # Parameters a shape may have at most one value of: sh:minInclusive and # sh:maxInclusive (SHACL §4.3), sh:in (§4.8.3), sh:datatype and sh:nodeKind @@ -646,15 +650,7 @@ def _slot_condition_shape( "definitely true" form with *definite*, otherwise its "not false" form.""" slot = self._condition_slot(cls, slot_name) pnode = BNode() - repeated = [] - - def prop_pv(p, v): - if v is None: - return - if p in self._SINGLE_VALUE_PARAMETERS and (pnode, p, None) in g: - repeated.append((p, v)) - else: - g.add((pnode, p, v)) + prop_pv, finish = self._shape_parameters(g, pnode) prop_pv(SH.path, URIRef(self._slot_iri(slot))) if condition.title is not None: @@ -668,13 +664,62 @@ def prop_pv(p, v): if upper is not None: prop_pv(SH.maxCount, Literal(upper)) + self._add_value_constraints(g, prop_pv, condition, condition.range or slot.range, definite) + if condition.has_member is not None: + prop_pv(SH.qualifiedValueShape, self._member_shape(g, slot, condition.has_member, definite)) + prop_pv(SH.qualifiedMinCount, Literal(1)) + finish() + return pnode + + def _shape_parameters(self, g: Graph, node: BNode) -> tuple[Callable, Callable[[], None]]: + """A function adding a parameter to the shape *node*, and one to call once all are added. + + A shape may have at most one value of each of :attr:`_SINGLE_VALUE_PARAMETERS`. + A further value goes into an ``sh:and`` member shape of its own, where it + applies to the same values, as it would on *node* itself. + """ + repeated = [] + + def add(p, v): + if v is None: + return + if p in self._SINGLE_VALUE_PARAMETERS and (node, p, None) in g: + repeated.append((p, v)) + else: + g.add((node, p, v)) + + def finish(): + if repeated: + members = [] + for p, v in repeated: + member = BNode() + g.add((member, p, v)) + members.append(member) + and_node = BNode() + Collection(g, and_node, members) + g.add((node, SH["and"], and_node)) + + return add, finish + + def _add_value_constraints( + self, + g: Graph, + pv: Callable, + condition: SlotDefinition | AnonymousSlotExpression, + value_range: ElementName | None, + definite: bool, + ) -> None: + """Add the constraints that *condition* places on each value of range *value_range*. + + These are the value operators, ``range`` and ``range_expression``, the + latter in its "definitely true" form with *definite*. + """ if condition.minimum_value is not None: - prop_pv(SH.minInclusive, Literal(condition.minimum_value)) + pv(SH.minInclusive, Literal(condition.minimum_value)) if condition.maximum_value is not None: - prop_pv(SH.maxInclusive, Literal(condition.maximum_value)) + pv(SH.maxInclusive, Literal(condition.maximum_value)) if condition.pattern is not None: - prop_pv(SH.pattern, Literal(condition.pattern)) - value_range = condition.range or slot.range + pv(SH.pattern, Literal(condition.pattern)) for values in ( [condition.equals_string] if condition.equals_string is not None else [], condition.equals_string_in, @@ -682,29 +727,31 @@ def prop_pv(p, v): if values: in_node = BNode() Collection(g, in_node, self._string_value_terms(value_range, values)) - prop_pv(SH["in"], in_node) + pv(SH["in"], in_node) if condition.equals_number is not None: # A value comparison, unlike the slot loop's sh:hasValue: 5 matches 5.0, # and like every other value constraint in a condition it holds when the # slot is absent. - prop_pv(SH.minInclusive, Literal(condition.equals_number)) - prop_pv(SH.maxInclusive, Literal(condition.equals_number)) + pv(SH.minInclusive, Literal(condition.equals_number)) + pv(SH.maxInclusive, Literal(condition.equals_number)) if condition.range is not None: - self._add_range(g, prop_pv, condition.range) + self._add_range(g, pv, condition.range) if condition.range_expression is not None: - prop_pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) - if repeated: - # Each repeated parameter in a member shape of its own: all of them hold - # for every value, as they would on the property shape itself. - members = [] - for p, v in repeated: - member = BNode() - g.add((member, p, v)) - members.append(member) - and_node = BNode() - Collection(g, and_node, members) - g.add((pnode, SH["and"], and_node)) - return pnode + pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) + + def _member_shape(self, g: Graph, slot: SlotDefinition, member: AnonymousSlotExpression, definite: bool) -> BNode: + """Build the node shape a value of *slot* must conform to as a member of its ``has_member``. + + ``sh:qualifiedValueShape`` with ``sh:qualifiedMinCount 1`` (SHACL §4.7.3) + then asks for at least one such value, while the other values need only + satisfy the slot's own constraints. *definite* selects the form of a + ``range_expression`` of *member*. + """ + node = BNode() + pv, finish = self._shape_parameters(g, node) + self._add_value_constraints(g, pv, member, member.range or slot.range, definite) + finish() + return node def _string_value_terms(self, r: ElementName | None, values: list[str]) -> list[URIRef | Literal]: """The RDF terms of the ``equals_string`` / ``equals_string_in`` *values* of a slot with range *r*. @@ -780,17 +827,13 @@ def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpre if slot.identifier: # An identifier is the node's IRI, not a property arc. return f"a condition on the identifier slot '{slot_name}'" - if condition.range is not None and not self._is_known_range(condition.range): - return f"the unknown range '{condition.range}' in the condition on slot '{slot_name}'" - value_range = condition.range or slot.range - if condition.range_expression is not None: - reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + reason = self._value_untranslatable(condition, slot, f"the condition on slot '{slot_name}'") + if reason is not None: + return reason + if condition.has_member is not None: + reason = self._member_untranslatable(slot, condition.has_member) if reason is not None: return reason - if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( - value_range - ): - return f"equals_string on slot '{slot_name}', whose range '{value_range}' does not hold strings" for operator in self._CLASS_EXPRESSION_OPERATORS: for member in getattr(expr, operator) or []: reason = self._untranslatable(cls, member) @@ -798,6 +841,38 @@ def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpre return reason return None + def _value_untranslatable( + self, condition: SlotDefinition | AnonymousSlotExpression, slot: SlotDefinition, where: str + ) -> str | None: + """Return what in the value constraints of *condition* on *slot* cannot be translated, or ``None``. + + *where* names *condition* in the explanation. + """ + if condition.range is not None and not self._is_known_range(condition.range): + return f"the unknown range '{condition.range}' in {where}" + value_range = condition.range or slot.range + if condition.range_expression is not None: + reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + if reason is not None: + return reason + if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( + value_range + ): + return f"equals_string in {where}, whose range '{value_range}' does not hold strings" + return None + + def _member_untranslatable(self, slot: SlotDefinition, member: AnonymousSlotExpression) -> str | None: + """Return what in the ``has_member`` *member* of *slot* cannot be translated, or ``None``. + + A member is one value, so only value constraints apply to it. + """ + if not slot.multivalued: + return f"has_member on slot '{slot.name}', which is not multivalued" + unknown = self._set_operator_fields(member) - self._SLOT_CONDITION_VALUE_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}' in the has_member of slot '{slot.name}'" + return self._value_untranslatable(member, slot, f"the has_member of slot '{slot.name}'") + def _value_class(self, value_range: ElementName | None) -> ClassDefinition | None: """The class whose instances a range *value_range* holds, or ``None`` for any other range.""" sv = self.schemaview @@ -833,19 +908,37 @@ def _add_range_expression( """ reason = self._untranslatable(self._value_class(value_range), expression) if reason is not None: - classes = self._range_expression_problems.setdefault((slot.name, reason), []) - if cls.name not in classes: - classes.append(cls.name) + self._slot_expression_problem(cls, slot, "range_expression", reason) return prop_pv(SH.node, self._range_expression_shape(g, value_range, expression, definite=False)) - def _report_range_expression_problems(self) -> None: - """Warn once about each ``range_expression`` of a slot that is not translated, - naming the class shapes it is missing from.""" - for (slot_name, reason), classes in self._range_expression_problems.items(): + def _add_member(self, g: Graph, prop_pv: Callable, cls: ClassDefinition, slot: SlotDefinition) -> None: + """Ask for at least one value of *slot*, in the shape of *cls*, satisfying its ``has_member``. + + Having no value, an absent slot fails it. A ``has_member`` that cannot be + translated is skipped with a warning. + """ + reason = self._member_untranslatable(slot, slot.has_member) + if reason is not None: + self._slot_expression_problem(cls, slot, "has_member", reason) + return + prop_pv(SH.qualifiedValueShape, self._member_shape(g, slot, slot.has_member, definite=False)) + prop_pv(SH.qualifiedMinCount, Literal(1)) + + def _slot_expression_problem(self, cls: ClassDefinition, slot: SlotDefinition, field: str, reason: str) -> None: + """Record that *field* of *slot* is left out of the shape of *cls* because of *reason*.""" + classes = self._slot_expression_problems.setdefault((slot.name, field, reason), []) + if cls.name not in classes: + classes.append(cls.name) + + def _report_slot_expression_problems(self) -> None: + """Warn once about each ``range_expression`` or ``has_member`` of a slot that is + not translated, naming the class shapes it is missing from.""" + for (slot_name, field, reason), classes in self._slot_expression_problems.items(): logger.warning( - "Slot %r: range_expression is not translated to SHACL, because it uses %s (in the shapes of %s).", + "Slot %r: %s is not translated to SHACL, because it uses %s (in the shapes of %s).", slot_name, + field, reason, ", ".join(map(repr, classes)), ) diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index abe307c1fa..c62d9774d2 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -2823,6 +2823,9 @@ def test_membership(framework, name, quantification, expression, instance, is_va expected_behavior = ValidationBehavior.INCOMPLETE if framework in [SHACL, SQL_DDL_SQLITE, PANDERA_POLARS_CLASS]: expected_behavior = ValidationBehavior.INCOMPLETE + if framework == SHACL and quantification == "has_member" and s1_range != CLASS_D: + # sh:qualifiedValueShape; equals_string on a reference is not translated + expected_behavior = ValidationBehavior.IMPLEMENTS if framework == OWL and name == "all_obj_members_equals_string" and not is_valid: # This test case relies on punning, as s1 is used as both an OP and DP, # so we do not expect a DL-reasoner to be able to handle it diff --git a/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml b/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml index d9a4667ace..78b7c26703 100644 --- a/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml +++ b/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml @@ -24,25 +24,12 @@ schema: minimum_value: 2 maximum_value: 5 - int_list_with_has_member: - range: integer - multivalued: true - has_member: - minimum_value: 2 - maximum_value: 5 - string_list_with_all_members: range: string multivalued: true all_members: pattern: e.* - string_list_with_has_member: - range: string - multivalued: true - has_member: - pattern: e.* - classes: Test: tree_root: true @@ -50,9 +37,7 @@ schema: - int_list - string_list - int_list_with_all_members - - int_list_with_has_member - string_list_with_all_members - - string_list_with_has_member json_schema: properties: int_list: @@ -69,19 +54,10 @@ json_schema: minimum: 2 maximum: 5 type: array - int_list_with_has_member: - contains: - minimum: 2 - maximum: 5 - type: array string_list_with_all_members: items: pattern: e.* type: array - string_list_with_has_member: - contains: - pattern: e.* - type: array data_cases: - data: int_list: [2, 3, 4, 5] @@ -105,13 +81,6 @@ data_cases: - data: int_list_with_all_members: [1, 2, 3] error_message: Failed validating 'minimum' - - data: - int_list_with_has_member: [2, 3, 4, 5] - - data: - int_list_with_has_member: [0, 1, 2] - - data: - int_list_with_has_member: [6, 7, 8] - error_message: Failed validating 'contains' - data: string_list_with_all_members: - echo @@ -121,16 +90,3 @@ data_cases: - echo - foxtrot error_message: Failed validating 'pattern' - - data: - string_list_with_has_member: - - echo - - elephant - - data: - string_list_with_has_member: - - echo - - foxtrot - - data: - string_list_with_has_member: - - foxtrot - - golf - error_message: Failed validating 'contains' diff --git a/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml b/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml new file mode 100644 index 0000000000..ce52cf7a38 --- /dev/null +++ b/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml @@ -0,0 +1,80 @@ +schema: + id: http://example.org/jsonschema_multivalued_has_member + name: jsonschema_multivalued_has_member + + imports: + - https://w3id.org/linkml/types + + slots: + int_list_with_has_member: + range: integer + multivalued: true + has_member: + minimum_value: 2 + maximum_value: 5 + + string_list_with_has_member: + range: string + multivalued: true + has_member: + pattern: e.* + + classes: + Test: + tree_root: true + slots: + - int_list_with_has_member + - string_list_with_has_member +json_schema: + properties: + int_list_with_has_member: + contains: + minimum: 2 + maximum: 5 + type: array + string_list_with_has_member: + contains: + pattern: e.* + type: array + required: + - int_list_with_has_member + - string_list_with_has_member +data_cases: + - data: + int_list_with_has_member: [2, 3, 4, 5] + string_list_with_has_member: [echo] + - data: + int_list_with_has_member: [0, 1, 2] + string_list_with_has_member: [echo] + - data: + int_list_with_has_member: [6, 7, 8] + string_list_with_has_member: [echo] + error_message: Failed validating 'contains' + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - echo + - elephant + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - echo + - foxtrot + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - foxtrot + - golf + error_message: Failed validating 'contains' + # a member is a value, so a list without a value has no member + - data: + int_list_with_has_member: [] + string_list_with_has_member: [echo] + error_message: Failed validating 'contains' + - data: + int_list_with_has_member: null + string_list_with_has_member: [echo] + error_message: Failed validating 'type' + - data: + string_list_with_has_member: [echo] + error_message: Failed validating 'required' diff --git a/tests/linkml/test_generators/test_has_member.py b/tests/linkml/test_generators/test_has_member.py new file mode 100644 index 0000000000..575d8e909d --- /dev/null +++ b/tests/linkml/test_generators/test_has_member.py @@ -0,0 +1,306 @@ +"""``has_member`` asks for a value satisfying an expression alike in the JSON Schema and SHACL generators. + +A member is one value of a multivalued slot, so a slot without a value, absent +or empty, has no member. In a condition of a class-level expression, +``has_member`` therefore decides whether the slot may be absent, and its member +takes the form of the condition. The agreement tests validate the same +instance with both generated artifacts, loading it as RDF through the +generated JSON-LD context. +""" + +import json +import logging +from typing import Any + +import pytest +import yaml +from jsonschema import Draft201909Validator +from pyshacl import validate + +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from tests.linkml.utils import generator_agreement + +PREFIX = "@prefix ex: . " + + +@pytest.fixture +def schema() -> dict[str, Any]: + """A collection of tags, scores and inlined items.""" + return yaml.safe_load(""" +id: https://example.org/membership +name: membership +prefixes: + ex: https://example.org/membership/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Collection: + tree_root: true + slots: [tags, scores, items] + Item: + slots: [status] +slots: + tags: {range: string, multivalued: true} + scores: {range: integer, multivalued: true} + items: {range: Item, multivalued: true, inlined_as_list: true} + status: {range: string} +""") + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any]) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of ``Collection``.""" + return generator_agreement.verdicts(schema, instance, "Collection") + + +def conforms(schema: dict[str, Any], data: str) -> bool: + """Whether Turtle *data* conforms to the SHACL shapes generated for *schema*, which must pass meta-SHACL.""" + shapes = ShaclGenerator(yaml.safe_dump(schema), closed=False).serialize() + return validate( + data_graph=PREFIX + data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + )[0] + + +def json_valid(schema: dict[str, Any], instance: dict[str, Any]) -> bool: + """Whether the JSON Schema generated for *schema* accepts *instance* of ``Collection``.""" + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Collection").serialize()) + return Draft201909Validator(json_schema).is_valid(instance) + + +_READY = {"range_expression": {"slot_conditions": {"status": {"equals_string": "ready"}}}} + + +@pytest.mark.parametrize( + "slot,member,values,valid", + [ + pytest.param("tags", {"equals_string": "reviewed"}, ["reviewed", "pending"], True, id="match"), + pytest.param("tags", {"equals_string": "reviewed"}, ["pending"], False, id="no-match"), + # a slot without a value has no member + pytest.param("tags", {"equals_string": "reviewed"}, [], False, id="empty"), + pytest.param("tags", {"equals_string": "reviewed"}, None, False, id="absent"), + pytest.param("tags", {}, ["anything"], True, id="any-value"), + pytest.param("tags", {}, None, False, id="any-value-absent"), + pytest.param("tags", {"equals_string_in": ["a", "b"], "pattern": "^a"}, ["a", "b"], True, id="conjunction"), + pytest.param("tags", {"equals_string_in": ["a", "b"], "pattern": "^a"}, ["b"], False, id="conjunction-other"), + pytest.param("scores", {"minimum_value": 10}, [9, 10], True, id="bound"), + pytest.param("scores", {"minimum_value": 10}, [8, 9], False, id="bound-other"), + # two values of one SHACL parameter hold together + pytest.param("scores", {"minimum_value": 0, "equals_number": 0}, [-1, 0], True, id="repeated"), + pytest.param("scores", {"minimum_value": 0, "equals_number": 0}, [-1, 1], False, id="repeated-other"), + pytest.param("scores", {"minimum_value": 5, "equals_number": 3}, [3, 5], False, id="contradiction"), + pytest.param("scores", {"minimum_value": 5, "equals_number": 7}, [6], False, id="repeated-second"), + pytest.param("items", _READY, [{"status": "draft"}, {"status": "ready"}], True, id="range_expression"), + pytest.param("items", _READY, [{"status": "draft"}], False, id="range_expression-other"), + # a value whose status is unknown is not definitely no member + pytest.param("items", _READY, [{"status": "draft"}, {}], True, id="range_expression-unknown"), + ], +) +def test_slot_has_member( + schema: dict[str, Any], slot: str, member: dict[str, Any], values: list | None, valid: bool +) -> None: + """At least one value of the slot must satisfy the member expression; the others need not.""" + schema["slots"][slot]["has_member"] = member + assert verdicts(schema, {} if values is None else {slot: values}) == (valid, valid) + + +_REVIEWED = {"has_member": {"equals_string": "reviewed"}} + + +@pytest.mark.parametrize( + "expression,values,valid", + [ + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, None, False, id="all_of-absent"), + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["reviewed"], True, id="all_of"), + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending"], False, id="all_of-other"), + # has_member decides presence, so it is false, not unknown, for an absent slot + pytest.param({"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, None, True, id="none_of-absent"), + pytest.param({"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending"], True, id="none_of-other"), + pytest.param( + {"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending", "reviewed"], False, id="none_of" + ), + pytest.param( + {"any_of": [{"slot_conditions": {"tags": _REVIEWED}}, {"slot_conditions": {"scores": {"required": True}}}]}, + None, + False, + id="any_of-absent", + ), + ], +) +def test_has_member_in_class_expression( + schema: dict[str, Any], expression: dict[str, Any], values: list | None, valid: bool +) -> None: + """In a condition of a class-level expression, ``has_member`` is false for a slot without a value.""" + schema["classes"]["Collection"].update(expression) + assert verdicts(schema, {} if values is None else {"tags": values}) == (valid, valid) + + +@pytest.mark.parametrize( + "items,valid", + [ + pytest.param([{}], True, id="unknown"), + pytest.param([{"status": "draft"}], True, id="other"), + pytest.param([{"status": "draft"}, {"status": "ready"}], False, id="match"), + ], +) +def test_member_under_negation_takes_the_definite_form(schema: dict[str, Any], items: list, valid: bool) -> None: + """Under ``none_of``, a member must definitely satisfy its expression to count.""" + schema["classes"]["Collection"]["none_of"] = [{"slot_conditions": {"items": {"has_member": _READY}}}] + assert verdicts(schema, {"items": items}) == (valid, valid) + + +@pytest.mark.parametrize("scores,valid", [([5], True), ([-1, 11], True), ([11], False), ([-1], False)]) +def test_members_of_two_conditions_may_be_one_value(schema: dict[str, Any], scores: list[int], valid: bool) -> None: + """Each ``has_member`` asks for a value of its own expression, which may or may not be the same value.""" + schema["classes"]["Collection"]["all_of"] = [ + {"slot_conditions": {"scores": {"has_member": {"minimum_value": 0}}}}, + {"slot_conditions": {"scores": {"has_member": {"maximum_value": 10}}}}, + ] + assert verdicts(schema, {"scores": scores}) == (valid, valid) + + +@pytest.mark.parametrize("statuses,valid", [(["draft", "ready"], True), (["draft"], False), ([], False)]) +@pytest.mark.parametrize("in_class_expression", [False, True]) +def test_has_member_on_values_inlined_as_dict( + schema: dict[str, Any], statuses: list[str], valid: bool, in_class_expression: bool +) -> None: + """The members of a slot inlined as a dict are the values of the dict.""" + schema["classes"]["Item"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + del schema["slots"]["items"]["inlined_as_list"] + schema["slots"]["items"]["inlined"] = True + if in_class_expression: + schema["classes"]["Collection"]["all_of"] = [{"slot_conditions": {"items": {"has_member": _READY}}}] + else: + schema["slots"]["items"]["has_member"] = _READY + items = {f"i{n}": {"status": status} for n, status in enumerate(statuses)} + assert json_valid(schema, {"items": items}) is valid + data = "ex:c a ex:Collection" + "".join(f"; ex:items ex:{key}" for key in items) + " . " + data += "".join(f'ex:{key} a ex:Item; ex:status "{value["status"]}" . ' for key, value in items.items()) + assert conforms(schema, data) is valid + + +@pytest.mark.parametrize("target", ["Collection", "SpecialCollection"]) +@pytest.mark.parametrize( + "values,valid", + [(["reviewed", "pending"], True), (["pending"], False), (None, False)], + ids=["match", "other", "absent"], +) +def test_has_member_is_inherited(schema: dict[str, Any], target: str, values: list | None, valid: bool) -> None: + """A subclass keeps the ``has_member`` its parent places on a slot through ``slot_usage``.""" + schema["classes"]["Collection"]["slot_usage"] = {"tags": _REVIEWED} + schema["classes"]["SpecialCollection"] = {"is_a": "Collection"} + instance = {} if values is None else {"tags": values} + assert generator_agreement.verdicts(schema, instance, target) == (valid, valid) + + +def test_has_member_on_a_single_valued_slot_is_ignored_with_a_warning( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A member is a value of a multivalued slot, so both generators ignore ``has_member`` on a + single-valued slot and say so.""" + schema["slots"]["tags"] = {"range": "string", "has_member": {"equals_string": "reviewed"}} + with caplog.at_level(logging.WARNING): + assert verdicts(schema, {"tags": "pending"}) == (True, True) + assert sorted({r.getMessage() for r in caplog.records if r.name.startswith("linkml.generators.")}) == [ + "Slot 'tags': has_member is not translated to JSON Schema, because the slot is not multivalued.", + "Slot 'tags': has_member is not translated to SHACL, because it uses has_member on slot 'tags', " + "which is not multivalued (in the shapes of 'Collection').", + ] + + +@pytest.mark.parametrize("typed,valid", [(True, True), (False, False)]) +def test_member_range_needs_the_class(schema: dict[str, Any], typed: bool, valid: bool) -> None: + """A member with a class ``range`` must be an instance of that class.""" + schema["classes"]["Special"] = {"is_a": "Item", "slot_usage": {"status": {"required": True}}} + schema["slots"]["items"]["has_member"] = {"range": "Special"} + special = "ex:Special, " if typed else "" + assert conforms(schema, f'ex:c a ex:Collection; ex:items [a {special}ex:Item; ex:status "ready"] .') is valid + assert json_valid(schema, {"items": [{"status": "ready"}]}) + assert not json_valid(schema, {"items": [{}]}) + + +@pytest.mark.parametrize("dict_inlined", [False, True]) +def test_null_has_no_member(schema: dict[str, Any], dict_inlined: bool) -> None: + """A JSON ``null`` is no value, so it has no member, although the generator otherwise allows ``null``.""" + if dict_inlined: + schema["classes"]["Item"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + del schema["slots"]["items"]["inlined_as_list"] + schema["slots"]["items"]["inlined"] = True + schema["slots"]["items"]["has_member"] = {} + assert not json_valid(schema, {"items": None}) + del schema["slots"]["items"]["has_member"] + assert json_valid(schema, {"items": None}) + + +@pytest.mark.parametrize("grade,valid", [(3, True), (1, False)]) +def test_member_range_resolves_its_range_expression(schema: dict[str, Any], grade: int, valid: bool) -> None: + """The ``range_expression`` of a member with a ``range`` constrains the slots of that range class.""" + schema["classes"]["Special"] = {"is_a": "Item", "attributes": {"grade": {"range": "integer"}}} + expression = {"slot_conditions": {"grade": {"minimum_value": 2}}} + schema["slots"]["items"]["has_member"] = {"range": "Special", "range_expression": expression} + assert conforms(schema, f"ex:c a ex:Collection; ex:items [a ex:Item, ex:Special; ex:grade {grade}] .") is valid + + +@pytest.mark.parametrize( + "slot_change,member,reason", + [ + pytest.param( + {"multivalued": False}, + {"equals_string": "reviewed"}, + "has_member on slot 'tags', which is not multivalued", + id="single-valued", + ), + pytest.param( + {}, + {"minimum_cardinality": 1}, + "'minimum_cardinality' in the has_member of slot 'tags'", + id="cardinality-of-a-value", + ), + pytest.param( + {}, + {"any_of": [{"equals_string": "reviewed"}]}, + "'any_of' in the has_member of slot 'tags'", + id="slot-operator", + ), + pytest.param( + {"range": "integer"}, + {"equals_string": "5"}, + "equals_string in the has_member of slot 'tags', whose range 'integer' does not hold strings", + id="string-on-integer", + ), + ], +) +def test_untranslatable_has_member_skipped_with_warning( + schema: dict[str, Any], + caplog: pytest.LogCaptureFixture, + slot_change: dict[str, Any], + member: dict[str, Any], + reason: str, +) -> None: + """A ``has_member`` SHACL cannot express is left out of the shapes, with one warning.""" + schema["slots"]["tags"].update(slot_change, has_member=member) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, "ex:c a ex:Collection .") + assert [r.getMessage() for r in caplog.records] == [ + f"Slot 'tags': has_member is not translated to SHACL, because it uses {reason} (in the shapes of 'Collection')." + ] + + +def test_untranslatable_has_member_in_class_expression_skips_it( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A class-level expression whose ``has_member`` cannot be translated is skipped as a whole.""" + member = {"any_of": [{"equals_string": "reviewed"}]} + schema["classes"]["Collection"]["all_of"] = [{"slot_conditions": {"tags": {"has_member": member}}}] + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, "ex:c a ex:Collection .") + assert [r.getMessage() for r in caplog.records] == [ + "Class 'Collection': all_of is not translated to SHACL, because it uses " + "'any_of' in the has_member of slot 'tags'." + ] diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index 785da1517a..1ac6173d6f 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -490,6 +490,12 @@ def test_multivalued_element_constraints(subtests, input_path): external_file_test(subtests, input_path("jsonschema_multivalued_element_constraints.yaml")) +def test_multivalued_has_member(subtests, input_path): + """Tests that has_member asks for a value satisfying its expression, which an empty or absent list lacks.""" + + external_file_test(subtests, input_path("jsonschema_multivalued_has_member.yaml")) + + def test_collection_forms(subtests, input_path): """Tests that expanded, compact, and simple dicts can be validated""" diff --git a/tests/linkml/test_generators/test_range_expression.py b/tests/linkml/test_generators/test_range_expression.py index 71a3ee71bb..3f6681bc67 100644 --- a/tests/linkml/test_generators/test_range_expression.py +++ b/tests/linkml/test_generators/test_range_expression.py @@ -20,10 +20,9 @@ from rdflib import Graph, Namespace, URIRef from rdflib.namespace import SH -from linkml.generators.jsonldcontextgen import ContextGenerator from linkml.generators.jsonschemagen import JsonSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator, cli -from linkml_runtime import SchemaView +from tests.linkml.utils import generator_agreement EX = Namespace("https://example.org/context/") PREFIX = f"@prefix ex: <{EX}> . " @@ -66,40 +65,9 @@ def schema() -> dict[str, Any]: """) -def _as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: - """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" - class_name = obj.get("@type", class_name) - typed = {**obj, "@type": class_name} - for slot in sv.class_induced_slots(class_name): - value = obj.get(slot.name) - if slot.range in sv.all_classes() and value is not None: - values = [_as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] - typed[slot.name] = values if isinstance(value, list) else values[0] - return typed - - -def _without_types(obj: Any) -> Any: - """*obj* without its ``@type`` keys.""" - if isinstance(obj, dict): - return {k: _without_types(v) for k, v in obj.items() if k != "@type"} - if isinstance(obj, list): - return [_without_types(v) for v in obj] - return obj - - def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str = "Document") -> tuple[bool, bool]: """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*.""" - text = yaml.safe_dump(schema) - json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) - context = json.loads(ContextGenerator(text).serialize())["@context"] - data = {"@context": context, **_as_json_ld(SchemaView(text), instance, target)} - shapes = ShaclGenerator(text, closed=False).serialize() - conforms, _, _ = validate( - Graph().parse(data=json.dumps(data), format="json-ld"), - shacl_graph=Graph().parse(data=shapes, format="turtle"), - meta_shacl=True, - ) - return Draft201909Validator(json_schema).is_valid(_without_types(instance)), conforms + return generator_agreement.verdicts(schema, instance, target) def conforms(schema: dict[str, Any], data: str, **options: Any) -> bool: diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index e07e35e443..17725b6180 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -3510,15 +3510,15 @@ def test_classes_sharing_a_class_uri_carry_an_inherited_expression_once(): pytest.param( None, {EX_CE.Parent: False, EX_CE.Child: False, EX_CE.GrandChild: False}, - "Class 'Parent': any_of is not translated to SHACL, because it uses 'has_member' in the condition on " + "Class 'Parent': any_of is not translated to SHACL, because it uses 'all_members' in the condition on " "slot 'a' (in the shapes of 'Parent', 'Child', 'GrandChild').", id="untranslatable-everywhere", ), pytest.param( {"a": {"range": "integer"}}, {EX_CE.Parent: True, EX_CE.Child: False, EX_CE.GrandChild: False}, - "Class 'Parent': any_of is not translated to SHACL, because it uses equals_string on slot 'a', whose " - "range 'integer' does not hold strings (in the shapes of 'Child', 'GrandChild').", + "Class 'Parent': any_of is not translated to SHACL, because it uses equals_string in the condition on " + "slot 'a', whose range 'integer' does not hold strings (in the shapes of 'Child', 'GrandChild').", id="untranslatable-in-subclasses", ), ], @@ -3528,7 +3528,7 @@ def test_untranslatable_inherited_class_expression_warned_once(caplog, child_usa shapes where it cannot, and reported once, naming them.""" schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) schema["classes"]["Parent"]["any_of"] = ( - [{"slot_conditions": {"a": {"has_member": {"equals_string": "x"}}}}] + [{"slot_conditions": {"a": {"all_members": {"equals_string": "x"}}}}] if child_usage is None else [{"slot_conditions": {"a": {"equals_string": "x"}}}] ) @@ -3560,7 +3560,7 @@ def test_class_expression_untranslatable_operator_skipped_with_warning(caplog): any_of: - slot_conditions: tags: - has_member: + all_members: equals_string: x - slot_conditions: a: @@ -3576,7 +3576,7 @@ def test_class_expression_untranslatable_operator_skipped_with_warning(caplog): assert (EX_CE.Thing, SH["or"], None) not in g assert any( - "any_of" in rec.message and "has_member" in rec.message and "tags" in rec.message for rec in caplog.records + "any_of" in rec.message and "all_members" in rec.message and "tags" in rec.message for rec in caplog.records ) # the translatable none_of is kept, and enforced shacl_ttl = g.serialize(format="turtle") @@ -4300,14 +4300,14 @@ def test_class_expression_range_with_identifier_is_an_iri(): _UNTRANSLATABLE_MEMBERS = { "untranslatable-second-member": ( - "has_member", + "all_members", """ - slot_conditions: a: required: true - slot_conditions: tags: - has_member: + all_members: equals_string: x""", ), "unknown-condition-range": ( @@ -4321,12 +4321,12 @@ def test_class_expression_range_with_identifier_is_an_iri(): required: true""", ), "untranslatable-nested-member": ( - "has_member", + "all_members", """ - all_of: - slot_conditions: tags: - has_member: + all_members: equals_string: x - slot_conditions: a: diff --git a/tests/linkml/utils/generator_agreement.py b/tests/linkml/utils/generator_agreement.py new file mode 100644 index 0000000000..92a885b0e8 --- /dev/null +++ b/tests/linkml/utils/generator_agreement.py @@ -0,0 +1,54 @@ +"""Validate the same instance against the JSON Schema and the SHACL shapes generated for a schema.""" + +import json +from typing import Any + +import yaml +from jsonschema import Draft201909Validator +from pyshacl import validate +from rdflib import Graph + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml_runtime import SchemaView + + +def as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: + """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" + class_name = obj.get("@type", class_name) + typed = {**obj, "@type": class_name} + for slot in sv.class_induced_slots(class_name): + value = obj.get(slot.name) + if slot.range in sv.all_classes() and value is not None: + values = [as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] + typed[slot.name] = values if isinstance(value, list) else values[0] + return typed + + +def without_types(obj: Any) -> Any: + """*obj* without its ``@type`` keys.""" + if isinstance(obj, dict): + return {k: without_types(v) for k, v in obj.items() if k != "@type"} + if isinstance(obj, list): + return [without_types(v) for v in obj] + return obj + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*. + + For SHACL, *instance* is loaded as RDF through the generated JSON-LD context, + and the shapes must pass meta-SHACL. + """ + text = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) + context = json.loads(ContextGenerator(text).serialize())["@context"] + data = {"@context": context, **as_json_ld(SchemaView(text), instance, target)} + shapes = ShaclGenerator(text, closed=False).serialize() + conforms, _, _ = validate( + Graph().parse(data=json.dumps(data), format="json-ld"), + shacl_graph=Graph().parse(data=shapes, format="turtle"), + meta_shacl=True, + ) + return Draft201909Validator(json_schema).is_valid(without_types(instance)), conforms