Skip to content

Commit 36ac9be

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 346f415 commit 36ac9be

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

linkml_runtime/processing/inlining.py

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

0 commit comments

Comments
 (0)