Skip to content

Commit a9453e1

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 a9453e1

File tree

2 files changed

+237
-0
lines changed

2 files changed

+237
-0
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from logging import Logger
2+
from typing import cast
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_report(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 debug_output(range_class_has_identifier: bool) -> None:
24+
msg = (
25+
f"Slot '{slot.name}' has a complete inlining specification: "
26+
+ f"range class has identifier: {range_class_has_identifier},"
27+
)
28+
if slot.inlined_as_list is None:
29+
msg += f"`inlined: {slot.inlined}`, `inlined_as_list` unspecified."
30+
else:
31+
msg += f"`inlined: {slot.inlined}` and `inlined_as_list: {slot.inlined_as_list}`"
32+
logger.debug(msg)
33+
34+
def warning(range_class_has_identifier: bool) -> None:
35+
text_identifier = "with an identifier" if range_class_has_identifier else "without an identifier"
36+
msg = (
37+
f"Slot '{slot.name}' has following illogic or incomplete inlining "
38+
+ f"specification: `inlined: {slot.inlined}` and `inlined_as_list: "
39+
+ f"{slot.inlined_as_list}` for objects {text_identifier}"
40+
)
41+
logger.warning(msg)
42+
43+
function_map = {
44+
# OK
45+
(True, True, True): debug_output,
46+
# OK
47+
(True, True, False): debug_output,
48+
# what type of inlining to use?
49+
(True, True, None): warning,
50+
# overriding specified value!!
51+
(True, False, True): set_inlined_and_report,
52+
# why specifying inlining type if no inlining?
53+
(True, False, False): warning,
54+
# OK
55+
(True, False, None): debug_output,
56+
# applying implicit default!!
57+
(True, None, True): set_inlined_and_report,
58+
# why specifying inlining type if inlining not requested?
59+
(True, None, False): warning,
60+
# no defaults, in-code implicit defaults will apply
61+
(True, None, None): warning,
62+
# OK
63+
(False, True, True): debug_output,
64+
# how to select a key for an object without an identifier?
65+
(False, True, False): warning,
66+
# no defaults, in-code implicit defaults will apply
67+
(False, True, None): warning,
68+
# how to add a reference to an object without an identifier?
69+
(False, False, True): warning,
70+
# how to add a reference to an object without an identifier?
71+
(False, False, False): warning,
72+
# how to add a reference to an object without an identifier?
73+
(False, False, None): warning,
74+
# applying implicit default!!
75+
(False, None, True): set_inlined_and_report,
76+
# why specifying inlining type if inlining not requested?
77+
(False, None, False): warning,
78+
# no defaults, in-code implicit defaults will apply
79+
(False, None, None): warning,
80+
}
81+
82+
def dispatch(range_class_has_identifier, inlined, inlined_as_list):
83+
# func = function_map.get((range_class_has_identifier, inlined, inlined_as_list), default_function)
84+
func = function_map.get((range_class_has_identifier, inlined, inlined_as_list))
85+
return func(range_class_has_identifier)
86+
87+
return dispatch
88+
89+
90+
def process(slot: SlotDefinition, schema: SchemaDefinition, logger: Logger) -> None:
91+
"""
92+
Processing the inlining behavior of a slot, including the type of inlining
93+
(as a list or as a dictionary).
94+
95+
Processing encompasses analyzing the combination of elements relevant for
96+
object inlining (reporting the result of the analysis with different logging
97+
levels) and enforcing certain values.
98+
99+
It is important to take into account following:
100+
- slot.inlined and slot.inlined_as_list can have three different values:
101+
True, False or None (if nothing specified in the schema)
102+
- if a class has an identifier is a pure boolean
103+
104+
Changes to `inlined` are applied directly on the provided slot object.
105+
106+
:param slot: the slot to process
107+
:param schema: the schema in which the slot is contained
108+
:param logger: the logger to use
109+
"""
110+
111+
# first of all, validate that the values of `inlined` and `inlined_as_list` are legal
112+
# either `True` or `False` (if specified) or `None` (if nothing specified)
113+
for value in ("inlined", "inlined_as_list"):
114+
if getattr(slot, value) not in (True, False, None):
115+
raise ValueError(
116+
f"Invalid value for '{value}' in the schema for slot " + f"'{slot.name}': '{getattr(slot, value)}'"
117+
)
118+
range_class = schema.classes[cast(ClassDefinitionName, slot.range)]
119+
range_has_identifier = any([schema.slots[s].identifier or schema.slots[s].key for s in range_class.slots])
120+
121+
dispatcher = _create_function_dispatcher(slot, logger)
122+
dispatcher(range_has_identifier, slot.inlined, slot.inlined_as_list)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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, 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 = prepare_schema(with_identifier, inlined, inlined_as_list)
49+
inlining.process(slot, schema, 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+
# applying implicit default!!
61+
(True, None, True),
62+
# applying implicit default!!
63+
(False, None, True),
64+
],
65+
)
66+
def test_force_inlined(with_identifier, inlined, inlined_as_list, caplog):
67+
"""Test that combinations that end up forcing `inlined: true` does so and generate a warning."""
68+
logger = logging.getLogger("Test")
69+
caplog.set_level(logging.WARNING)
70+
71+
slot, schema = prepare_schema(with_identifier, inlined, inlined_as_list)
72+
inlining.process(slot, schema, logger)
73+
assert slot.inlined
74+
for logrecord in caplog.records:
75+
assert logrecord.levelname == "WARNING"
76+
assert "Forcing `inlined: true`!!" in logrecord.message
77+
78+
79+
@pytest.mark.parametrize(
80+
("with_identifier", "inlined", "inlined_as_list"),
81+
[
82+
# what type of inlining to use?
83+
(True, True, None),
84+
# why specifying inlining type if no inlining?
85+
(True, False, False),
86+
# why specifying inlining type if inlining not requested?
87+
(True, None, False),
88+
# no defaults, in-code implicit defaults will apply
89+
(True, None, None),
90+
# how to select a key for an object without an identifier?
91+
(False, True, False),
92+
# no defaults, in-code implicit defaults will apply
93+
(False, True, None),
94+
# how to add a reference to an object without an identifier?
95+
(False, False, True),
96+
# how to add a reference to an object without an identifier?
97+
(False, False, False),
98+
# how to add a reference to an object without an identifier?
99+
(False, False, None),
100+
# why specifying inlining type if inlining not requested?
101+
(False, None, False),
102+
# no defaults, in-code implicit defaults will apply
103+
(False, None, None),
104+
],
105+
)
106+
def test_warn_inconsistencies(with_identifier, inlined, inlined_as_list, caplog):
107+
"""Test that combinations that are somehow illogical or incomplete are flagged raising a warning."""
108+
logger = logging.getLogger("Test")
109+
caplog.set_level(logging.WARNING)
110+
111+
slot, schema = prepare_schema(with_identifier, inlined, inlined_as_list)
112+
inlining.process(slot, schema, logger)
113+
for logrecord in caplog.records:
114+
assert logrecord.levelname == "WARNING"
115+
assert "illogic or incomplete inlining specification" in logrecord.message

0 commit comments

Comments
 (0)