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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/generators/json-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://w3id.org/linkml/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
Expand Down
35 changes: 33 additions & 2 deletions docs/generators/shacl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <https://w3id.org/linkml/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
<https://www.w3.org/TR/shacl/#QualifiedValueShapeConstraintComponent>`__), 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
^^^^^^^^^^^^

Expand Down
4 changes: 3 additions & 1 deletion docs/schemas/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
24 changes: 17 additions & 7 deletions packages/linkml/src/linkml/generators/common/class_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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 (
Expand Down
106 changes: 90 additions & 16 deletions packages/linkml/src/linkml/generators/jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading