Skip to content

Commit 0722cd8

Browse files
committed
feat(processing): add module to process inlining
Trying to address issue 2813 [1] inconsistencies have been identified WRT how object inlining is behaving depending on the values of `inlined`, `inlined_as_list` and the presence/absence of an identifier in the range class. These inconsistencies appear from the fact that no normalization is happening on schema loading (not only in SchemaLoader, but also in SchemaView) and some consumers apply their own logic. This patch provides a module that should be used on any schema loading (as of now SchemaLoader and SchemaView) to have a common behavior. The code is structured in a way that it covers all potential combinations in an easy to understand manner. Unit testing is also provided for the module. [1]: linkml/linkml#2813 Signed-off-by: Silvano Cirujano Cuesta <silvano.cirujano-cuesta@siemens.com>
1 parent 0756a6c commit 0722cd8

File tree

2 files changed

+339
-0
lines changed

2 files changed

+339
-0
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from copy import deepcopy
2+
from logging import Logger
3+
from typing import cast
4+
5+
from linkml_runtime.linkml_model.meta import (
6+
ClassDefinitionName,
7+
SchemaDefinition,
8+
SlotDefinition,
9+
)
10+
11+
12+
def _create_function_dispatcher(slot: SlotDefinition, logger: Logger) -> None:
13+
"""Function dispatcher for slot inlining processing"""
14+
15+
def set_inlined_and_warn(range_class_has_identifier: bool) -> None:
16+
slot.inlined = True
17+
text_identifier = "with an identifier" if range_class_has_identifier else "without an identifier"
18+
msg = (
19+
f"Slot '{slot.name}' is requesting for an object {text_identifier} inlining as a "
20+
+ "list, but no inlining requested! Forcing `inlined: true`!!"
21+
)
22+
logger.warning(msg)
23+
24+
def set_inlined_and_report(range_class_has_identifier: bool) -> None:
25+
slot.inlined = True
26+
text_identifier = "with an identifier" if range_class_has_identifier else "without an identifier"
27+
msg = (
28+
f"Slot '{slot.name}' is requesting for an object {text_identifier} inlining as a "
29+
+ "list, but no inlining requested! Forcing `inlined: true`!!"
30+
)
31+
logger.info(msg)
32+
33+
def debug_output(range_class_has_identifier: bool) -> None:
34+
msg = (
35+
f"Slot '{slot.name}' has a complete inlining specification: "
36+
+ f"range class has identifier: {range_class_has_identifier},"
37+
)
38+
if slot.inlined_as_list is None:
39+
msg += f"`inlined: {slot.inlined}`, `inlined_as_list` unspecified."
40+
else:
41+
msg += f"`inlined: {slot.inlined}` and `inlined_as_list: {slot.inlined_as_list}`"
42+
logger.debug(msg)
43+
44+
def info(range_class_has_identifier: bool) -> None:
45+
text_identifier = "with an identifier" if range_class_has_identifier else "without an identifier"
46+
msg = (
47+
f"Slot '{slot.name}' has following illogic or incomplete inlining "
48+
+ f"specification: `inlined: {slot.inlined}` and `inlined_as_list: "
49+
+ f"{slot.inlined_as_list}` for objects {text_identifier}"
50+
)
51+
logger.info(msg)
52+
53+
function_map = {
54+
# OK
55+
(True, True, True): debug_output,
56+
# OK
57+
(True, True, False): debug_output,
58+
# what type of inlining to use?
59+
(True, True, None): info,
60+
# overriding specified value!!
61+
(True, False, True): set_inlined_and_warn,
62+
# why specifying inlining type if no inlining?
63+
(True, False, False): info,
64+
# OK
65+
(True, False, None): debug_output,
66+
# applying implicit default!!
67+
(True, None, True): set_inlined_and_report,
68+
# why specifying inlining type if inlining not requested?
69+
(True, None, False): info,
70+
# no defaults, in-code implicit defaults will apply
71+
(True, None, None): info,
72+
# OK
73+
(False, True, True): debug_output,
74+
# how to select a key for an object without an identifier?
75+
(False, True, False): info,
76+
# no defaults, in-code implicit defaults will apply
77+
(False, True, None): info,
78+
# how to add a reference to an object without an identifier?
79+
(False, False, True): info,
80+
# how to add a reference to an object without an identifier?
81+
(False, False, False): info,
82+
# how to add a reference to an object without an identifier?
83+
(False, False, None): info,
84+
# applying implicit default!!
85+
(False, None, True): set_inlined_and_report,
86+
# why specifying inlining type if inlining not requested?
87+
(False, None, False): info,
88+
# no defaults, in-code implicit defaults will apply
89+
(False, None, None): info,
90+
}
91+
92+
def dispatch(range_class_has_identifier, inlined, inlined_as_list):
93+
# func = function_map.get((range_class_has_identifier, inlined, inlined_as_list), default_function)
94+
func = function_map.get((range_class_has_identifier, inlined, inlined_as_list))
95+
return func(range_class_has_identifier)
96+
97+
return dispatch
98+
99+
100+
def process(slot: SlotDefinition, schema_map: dict[str, SchemaDefinition], logger: Logger) -> (bool, bool):
101+
"""
102+
Processing the inlining behavior of a slot, including the type of inlining
103+
(as a list or as a dictionary).
104+
105+
Processing encompasses analyzing the combination of elements relevant for
106+
object inlining (reporting the result of the analysis with different logging
107+
levels) and enforcing certain values.
108+
109+
It is important to take into account following:
110+
- slot.inlined and slot.inlined_as_list can have three different values:
111+
True, False or None (if nothing specified in the schema)
112+
- if a class has an identifier is a pure boolean
113+
114+
Changes to `inlined` are applied directly on the provided slot object.
115+
116+
:param slot: the slot to process
117+
:param schema: the schema in which the slot is contained
118+
:param logger: the logger to use
119+
"""
120+
121+
fixed_slot = deepcopy(slot)
122+
# first of all, validate that the values of `inlined` and `inlined_as_list` are legal
123+
# either `True` or `False` (if specified) or `None` (if nothing specified)
124+
for value in ("inlined", "inlined_as_list"):
125+
if getattr(fixed_slot, value) not in (True, False, None):
126+
raise ValueError(
127+
f"Invalid value for '{value}' in the schema for slot "
128+
+ f"'{fixed_slot.name}': '{getattr(fixed_slot, value)}'"
129+
)
130+
range_class = None
131+
for schema in schema_map.values():
132+
if cast(ClassDefinitionName, fixed_slot.range) in schema.classes:
133+
range_class = schema.classes[cast(ClassDefinitionName, fixed_slot.range)]
134+
break
135+
# range is a type
136+
if range_class is None:
137+
return (None, None)
138+
range_has_identifier = False
139+
for sn in range_class.slots:
140+
for schema in schema_map.values():
141+
if sn in schema.slots:
142+
range_has_identifier = bool(schema.slots[sn].identifier or schema.slots[sn].key)
143+
break
144+
else:
145+
continue
146+
break
147+
148+
dispatcher = _create_function_dispatcher(fixed_slot, logger)
149+
dispatcher(range_has_identifier, fixed_slot.inlined, fixed_slot.inlined_as_list)
150+
151+
return (fixed_slot.inlined, fixed_slot.inlined_as_list)
152+
153+
154+
def is_inlined(slot: SlotDefinition, schema_map: dict[str, SchemaDefinition], logger: Logger) -> (bool, bool):
155+
return bool(process(slot, schema_map, logger)[0])
156+
157+
158+
def is_inlined_as_list(slot: SlotDefinition, schema_map: dict[str, SchemaDefinition], logger: Logger) -> (bool, bool):
159+
return bool(process(slot, schema_map, logger)[1])
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import logging
2+
3+
import pytest
4+
5+
from linkml_runtime.linkml_model.meta import (
6+
ClassDefinition,
7+
SlotDefinition,
8+
)
9+
from linkml_runtime.processing import inlining
10+
from linkml_runtime.utils.schema_builder import SchemaBuilder
11+
12+
13+
def prepare_schema(with_identifier, inlined, inlined_as_list):
14+
builder = SchemaBuilder()
15+
16+
id = SlotDefinition(name="id", identifier=True)
17+
builder.add_slot(id)
18+
19+
range_class = ClassDefinition(name="RangeClass")
20+
if with_identifier:
21+
range_class.slots = ["id"]
22+
builder.add_class(range_class)
23+
24+
slot = SlotDefinition(name="slot_under_test", range=range_class.name)
25+
if isinstance(inlined, bool):
26+
slot.inlined = inlined
27+
if isinstance(inlined_as_list, bool):
28+
slot.inlined_as_list = inlined_as_list
29+
builder.add_slot(slot)
30+
31+
slot_type = SlotDefinition(name="slot_with_type", range="int")
32+
if isinstance(inlined, bool):
33+
slot_type.inlined = inlined
34+
if isinstance(inlined_as_list, bool):
35+
slot_type.inlined_as_list = inlined_as_list
36+
builder.add_slot(slot_type)
37+
38+
return (slot, slot_type, {"schema": builder.schema})
39+
40+
41+
@pytest.mark.parametrize(
42+
("with_identifier", "inlined", "inlined_as_list", "expected_inlined", "expected_inlined_as_list"),
43+
[
44+
(True, True, True, True, True),
45+
(True, True, False, True, False),
46+
(True, False, None, False, None),
47+
(False, True, True, True, True),
48+
],
49+
)
50+
def test_report_ok(with_identifier, inlined, inlined_as_list, expected_inlined, expected_inlined_as_list, caplog):
51+
"""Test that combinations that are clear an unproblematic only generate debug output."""
52+
logger = logging.getLogger("Test")
53+
caplog.set_level(logging.DEBUG)
54+
55+
slot, _, schema_map = prepare_schema(with_identifier, inlined, inlined_as_list)
56+
fixed_inlined, fixed_inlined_as_list = inlining.process(slot, schema_map, logger)
57+
assert fixed_inlined == expected_inlined
58+
assert fixed_inlined_as_list == expected_inlined_as_list
59+
for logrecord in caplog.records:
60+
assert logrecord.levelname == "DEBUG"
61+
assert " complete inlining specification" in logrecord.message
62+
63+
64+
@pytest.mark.parametrize(
65+
("with_identifier", "inlined", "inlined_as_list", "expected_inlined_as_list"),
66+
[
67+
# overriding specified `inlined: false` with `inlined: true`!!
68+
(True, False, True, True),
69+
],
70+
)
71+
def test_force_inlined(with_identifier, inlined, inlined_as_list, expected_inlined_as_list, caplog):
72+
"""Test that combinations that end up forcing `inlined: true` does so and generate a warning."""
73+
logger = logging.getLogger("Test")
74+
caplog.set_level(logging.WARNING)
75+
76+
slot, _, schema_map = prepare_schema(with_identifier, inlined, inlined_as_list)
77+
fixed_inlined, fixed_inlined_as_list = inlining.process(slot, schema_map, logger)
78+
assert fixed_inlined
79+
assert fixed_inlined_as_list == expected_inlined_as_list
80+
for logrecord in caplog.records:
81+
assert logrecord.levelname == "WARNING"
82+
assert "Forcing `inlined: true`!!" in logrecord.message
83+
84+
85+
@pytest.mark.parametrize(
86+
("with_identifier", "inlined", "inlined_as_list", "expected_inlined_as_list"),
87+
[
88+
# applying implicit default!!
89+
(True, None, True, True),
90+
# applying implicit default!!
91+
(False, None, True, True),
92+
],
93+
)
94+
def test_default_inlined(with_identifier, inlined, inlined_as_list, expected_inlined_as_list, caplog):
95+
"""Test that combinations that end up forcing `inlined: true` does so and generate a warning."""
96+
logger = logging.getLogger("Test")
97+
caplog.set_level(logging.INFO)
98+
99+
slot, _, schema_map = prepare_schema(with_identifier, inlined, inlined_as_list)
100+
fixed_inlined, fixed_inlined_as_list = inlining.process(slot, schema_map, logger)
101+
assert fixed_inlined
102+
assert fixed_inlined_as_list == expected_inlined_as_list
103+
for logrecord in caplog.records:
104+
assert logrecord.levelname == "INFO"
105+
assert "Forcing `inlined: true`!!" in logrecord.message
106+
107+
108+
@pytest.mark.parametrize(
109+
("with_identifier", "inlined", "inlined_as_list", "expected_inlined", "expected_inlined_as_list"),
110+
[
111+
# what type of inlining to use?
112+
(True, True, None, True, None),
113+
# why specifying inlining type if no inlining?
114+
(True, False, False, False, False),
115+
# why specifying inlining type if inlining not requested?
116+
(True, None, False, None, False),
117+
# no defaults, in-code implicit defaults will apply
118+
(True, None, None, None, None),
119+
# how to select a key for an object without an identifier?
120+
(False, True, False, True, False),
121+
# no defaults, in-code implicit defaults will apply
122+
(False, True, None, True, None),
123+
# how to add a reference to an object without an identifier?
124+
(False, False, True, False, True),
125+
# how to add a reference to an object without an identifier?
126+
(False, False, False, False, False),
127+
# how to add a reference to an object without an identifier?
128+
(False, False, None, False, None),
129+
# why specifying inlining type if inlining not requested?
130+
(False, None, False, None, False),
131+
# no defaults, in-code implicit defaults will apply
132+
(False, None, None, None, None),
133+
],
134+
)
135+
def test_info_inconsistencies(
136+
with_identifier, inlined, inlined_as_list, expected_inlined, expected_inlined_as_list, caplog
137+
):
138+
"""Test that combinations that are somehow illogical or incomplete are reported."""
139+
logger = logging.getLogger("Test")
140+
caplog.set_level(logging.INFO)
141+
142+
slot, _, schema_map = prepare_schema(with_identifier, inlined, inlined_as_list)
143+
fixed_inlined, fixed_inlined_as_list = inlining.process(slot, schema_map, logger)
144+
assert fixed_inlined == expected_inlined
145+
assert fixed_inlined_as_list == expected_inlined_as_list
146+
for logrecord in caplog.records:
147+
assert logrecord.levelname == "INFO"
148+
assert "illogic or incomplete inlining specification" in logrecord.message
149+
150+
151+
@pytest.mark.parametrize(
152+
("with_identifier", "inlined", "inlined_as_list"),
153+
[
154+
(True, True, True),
155+
(True, True, False),
156+
(True, True, None),
157+
(True, False, True),
158+
(True, False, False),
159+
(True, False, None),
160+
(True, None, True),
161+
(True, None, False),
162+
(True, None, None),
163+
(False, True, True),
164+
(False, True, False),
165+
(False, True, None),
166+
(False, False, True),
167+
(False, False, False),
168+
(False, False, None),
169+
(False, None, True),
170+
(False, None, False),
171+
(False, None, None),
172+
],
173+
)
174+
def test_slot_type(with_identifier, inlined, inlined_as_list):
175+
"""Test that slots with a type as range returns (None, None)."""
176+
logger = logging.getLogger("Test")
177+
_, slot, schema_map = prepare_schema(with_identifier, inlined, inlined_as_list)
178+
fixed_inlined, fixed_inlined_as_list = inlining.process(slot, schema_map, logger)
179+
assert fixed_inlined is None
180+
assert fixed_inlined_as_list is None

0 commit comments

Comments
 (0)