Skip to content

Commit 3c42fc7

Browse files
Merge pull request #471 from linkml/schemaview_induced_slot_range
schemaview.py: adding `induced_slot_range` function to retrieve the possible ranges for a slot
2 parents 9045b86 + b92676b commit 3c42fc7

File tree

2 files changed

+91
-14
lines changed

2 files changed

+91
-14
lines changed

linkml_runtime/utils/schemaview.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,12 +1875,61 @@ def slot_range_as_union(self, slot: SlotDefinition) -> list[ElementName]:
18751875
:param slot:
18761876
:return: list of ranges
18771877
"""
1878-
r = slot.range
1879-
range_union_of = [r]
1880-
for x in slot.exactly_one_of + slot.any_of:
1881-
if x.range:
1882-
range_union_of.append(x.range)
1883-
return range_union_of
1878+
return list({y.range for y in [slot, *[x for x in [*slot.exactly_one_of, *slot.any_of] if x.range]]})
1879+
1880+
def induced_slot_range(self, slot: SlotDefinition, strict: bool = False) -> set[str | ElementName]: # noqa: FBT001, FBT002
1881+
"""Retrieve all applicable ranges for a slot, falling back to the default if necessary.
1882+
1883+
Performs several validation checks if `strict` is True:
1884+
- ensures that the slot has a range specified
1885+
- requires the slot range to be set to a class with CURIE `linkml:Any` if one of the boolean specifiers is used for the range
1886+
- ensures that only one of `any_of` and `exactly_one_of` is used if the range is `linkml:Any`
1887+
1888+
:param slot: the slot to be investigated
1889+
:type slot: SlotDefinition
1890+
:param strict: whether or not to throw errors if there are validation issues with the schema, defaults to False
1891+
:type strict: bool, optional
1892+
:return: set of ranges
1893+
:rtype: set[str | ElementName]
1894+
"""
1895+
1896+
slot_range = slot.range
1897+
any_of_range = {x.range for x in slot.any_of if x.range}
1898+
exactly_one_of_range = {x.range for x in slot.exactly_one_of if x.range}
1899+
1900+
is_any = False
1901+
if slot_range:
1902+
range_class = self.get_class(slot_range)
1903+
if range_class and range_class.class_uri == "linkml:Any":
1904+
is_any = True
1905+
1906+
if strict:
1907+
# no range specified and no schema default
1908+
if not (any_of_range or exactly_one_of_range or slot_range):
1909+
err_msg = f"{slot.owner} slot {slot.name} has no range specified"
1910+
raise ValueError(err_msg)
1911+
1912+
# ensure that only one of any_of and exactly_one_of is specified
1913+
if any_of_range and exactly_one_of_range:
1914+
err_msg = f"{slot.owner} slot {slot.name} has range specified in both `exactly_one_of` and `any_of`"
1915+
raise ValueError(err_msg)
1916+
1917+
# if any_of or exactly_one_of is set, the slot range should be linkml:Any
1918+
if (any_of_range or exactly_one_of_range) and not (slot_range and is_any):
1919+
err_msg = f"{slot.owner} slot {slot.name} has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any"
1920+
raise ValueError(err_msg)
1921+
1922+
# if the range is linkml:Any and one/both of these ranges is set, return them
1923+
if is_any and (any_of_range or exactly_one_of_range):
1924+
return {*any_of_range, *exactly_one_of_range}
1925+
1926+
# return the slot range (if set)
1927+
if slot_range:
1928+
return {slot_range}
1929+
1930+
# return empty set (not {None})
1931+
# note: this is only returned when strict mode is False
1932+
return set()
18841933

18851934
def get_classes_by_slot(self, slot: SlotDefinition, include_induced: bool = False) -> list[ClassDefinitionName]:
18861935
"""Get all classes that use a given slot, either as a direct or induced slot.

tests/test_utils/test_schemaview.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,13 @@ def sv_range_riid_gen(request: pytest.FixtureRequest) -> tuple[SchemaView, tuple
16801680
for key, value in ranges_replaced_by_defaults["none_range"].items()
16811681
}
16821682

1683+
induced_range_strict_errors = {
1684+
"any_of_and_exactly_one_of_range": "ClassWithRanges slot any_of_and_exactly_one_of_range has range specified in both `exactly_one_of` and `any_of`",
1685+
"invalid_any_range_no_linkml_any": "ClassWithRanges slot invalid_any_range_no_linkml_any has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any",
1686+
"invalid_any_range_enum": "ClassWithRanges slot invalid_any_range_enum has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any",
1687+
"invalid_any_range_class": "ClassWithRanges slot invalid_any_range_class has range specified in `exactly_one_of` or `any_of` but the slot range is not linkml:Any",
1688+
}
1689+
16831690

16841691
def test_generated_range_schema(sv_range_riid_gen: tuple[SchemaView, tuple[str, str | None, str | None]]) -> None:
16851692
"""Tests for generation of range schemas.
@@ -1696,8 +1703,17 @@ def test_generated_range_schema(sv_range_riid_gen: tuple[SchemaView, tuple[str,
16961703
assert isinstance(sv_range, SchemaView)
16971704

16981705

1699-
@pytest.mark.parametrize("range_function", ["slot_range", "slot_range_as_union", "slot_applicable_range_elements"])
17001706
@pytest.mark.parametrize("slot_name", ranges_no_defaults.keys())
1707+
@pytest.mark.parametrize(
1708+
"range_function",
1709+
[
1710+
"slot_range",
1711+
"slot_range_as_union",
1712+
"induced_slot_range",
1713+
"induced_range_strict",
1714+
"slot_applicable_range_elements",
1715+
],
1716+
)
17011717
def test_slot_range(
17021718
range_function: str,
17031719
slot_name: str,
@@ -1713,25 +1729,37 @@ def test_slot_range(
17131729
:type sv_range_riid_gen: tuple[SchemaView, tuple[str, str | None, str | None]]
17141730
"""
17151731
(sv_range, range_tuple) = sv_range_riid_gen
1716-
1717-
slots_by_name = {s.name: s for s in sv_range.class_induced_slots("ClassWithRanges")}
1732+
slot_object = sv_range.induced_slot(slot_name, "ClassWithRanges")
17181733
expected = ranges_no_defaults[slot_name]
1734+
17191735
if slot_name in ranges_replaced_by_defaults:
17201736
expected = ranges_replaced_by_defaults[slot_name][range_tuple]
1737+
17211738
if range_function == "slot_range":
1722-
assert slots_by_name[slot_name].range == expected[0]
1739+
assert slot_object.range == expected[0]
17231740
elif range_function == "slot_range_as_union":
1724-
assert set(sv_range.slot_range_as_union(slots_by_name[slot_name])) == expected[1]
1741+
assert set(sv_range.slot_range_as_union(slot_object)) == expected[1]
17251742
elif range_function == "induced_slot_range":
1726-
assert sv_range.induced_slot_range(slots_by_name[slot_name]) == expected[2]
1743+
assert sv_range.induced_slot_range(slot_object) == expected[2]
1744+
elif range_function == "induced_range_strict":
1745+
# err_msg will be None if there is no error in the slot range specification
1746+
err_msg = induced_range_strict_errors.get(slot_name)
1747+
if not err_msg and expected[2] == set():
1748+
err_msg = f"ClassWithRanges slot {slot_name} has no range specified"
1749+
1750+
if err_msg:
1751+
with pytest.raises(ValueError, match=err_msg):
1752+
sv_range.induced_slot_range(slot_object, strict=True)
1753+
else:
1754+
assert sv_range.induced_slot_range(slot_object, strict=True) == expected[2]
17271755
elif range_function == "slot_applicable_range_elements":
17281756
if slot_name in ranges_replaced_by_defaults and len(expected) < 4:
17291757
expected = ranges_no_defaults[slot_name]
17301758
if isinstance(expected[3], set):
1731-
assert set(sv_range.slot_applicable_range_elements(slots_by_name[slot_name])) == expected[3]
1759+
assert set(sv_range.slot_applicable_range_elements(slot_object)) == expected[3]
17321760
else:
17331761
with pytest.raises(expected[3], match="Unrecognized range: None"):
1734-
sv_range.slot_applicable_range_elements(slots_by_name[slot_name])
1762+
sv_range.slot_applicable_range_elements(slot_object)
17351763
else:
17361764
pytest.fail(f"Unexpected range_function value: {range_function}")
17371765

0 commit comments

Comments
 (0)