Repository: linkml/linkml-runtime

Issue:
SchemaView not retrieving induced slot description
I have put together a small test to for retrieving an induced slots description.

Schema:
```
id: https://example.com/test-induced
name: test-induced
title: test induced

description: >-
    test schema view's finding of induced slot information.

classes:
    class1:
        slots:
            - slot1
        slot_usage:
            slot2:
                 description: induced slot2
slots:
    slot1:
        description: non-induced slot1
    slot2:
        description: non-induced slot2
```
When I run tests, this passes:
```
s1 = view.get_slot('slot1')
assert s1.description == "non-induced slot1"
```
But, this fails:
```
s2_induced = view.induced_slot("slot2", "class1")
assert s2_induced == "induced slot2"
```
Is this expected behavior?

I am attaching my test file, which I can turn into a unit test if need be.
[induced-slot-test.py.txt](https://github.com/linkml/linkml-runtime/files/7557818/induced-slot-test.py.txt)



Discussion:

Patch:
diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py
index d29392c..b100e24 100644
--- a/linkml_runtime/utils/schemaview.py
+++ b/linkml_runtime/utils/schemaview.py
@@ -775,7 +775,7 @@ class SchemaView(object):
         has all properties materialized.
 
         This makes use of schema slots, such as attributes, slot_usage. It also uses ancestor relationships
-        to infer missing values
+        to infer missing values, for inheritable slots
 
         :param slot_name: slot to be queries
         :param class_name: class used as context
@@ -786,8 +786,11 @@ class SchemaView(object):
         cls = self.get_class(class_name, imports)
         islot = None
         if slot is not None:
+            # case 1: there is an explicit declaration of the slot
             islot = deepcopy(slot)
         else:
+            # case 2: no independent slot definition, declared as an attribute
+            # (can be attribute of the class, or an ancestor)
             for an in self.class_ancestors(class_name):
                 a = self.get_class(an, imports)
                 if slot_name in a.attributes:
@@ -800,11 +803,16 @@ class SchemaView(object):
             'maximum_value': lambda x, y: min(x, y),
             'minimum_value': lambda x, y: max(x, y),
         }
-        for metaslot_name in SlotDefinition._inherited_slots:
+        # iterate through all metaslots, and potentially populate metaslot value for induced slot
+        for metaslot_name in self._metaslots_for_slot():
             # inheritance of slots; priority order
             #   slot-level assignment < ancestor slot_usage < self slot_usage
             v = getattr(islot, metaslot_name, None)
-            for an in reversed(self.class_ancestors(class_name)):
+            if metaslot_name in SlotDefinition._inherited_slots:
+                propagated_from = self.class_ancestors(class_name, reflexive=True, mixins=True)
+            else:
+                propagated_from = [class_name]
+            for an in reversed(propagated_from):
                 islot.owner = an
                 a = self.get_class(an, imports)
                 anc_slot_usage = a.slot_usage.get(slot_name, {})
@@ -828,6 +836,11 @@ class SchemaView(object):
             slot.inlined = True
         return deepcopy(islot)
 
+    @lru_cache()
+    def _metaslots_for_slot(self):
+        fake_slot = SlotDefinition('__FAKE')
+        return vars(fake_slot).keys()
+
     @lru_cache()
     def class_induced_slots(self, class_name: CLASS_NAME = None, imports=True) -> List[SlotDefinition]:
         """


Test patch:
diff --git a/tests/test_issues/test_linkml_runtime_issue_68.py b/tests/test_issues/test_linkml_runtime_issue_68.py
new file mode 100644
index 0000000..c122100
--- /dev/null
+++ b/tests/test_issues/test_linkml_runtime_issue_68.py
@@ -0,0 +1,125 @@
+import logging
+import unittest
+from unittest import TestCase
+from linkml_runtime.utils.schemaview import SchemaView
+
+schema_str = """
+id: https://example.com/test-induced
+name: test-induced
+title: test induced
+
+description: >-
+    test schema view's finding of induced slot information.
+
+classes:
+    class1:
+        slots:
+            - slot1
+            - slot2
+            - slot3
+        slot_usage:
+            slot2:
+                description: induced slot2
+                required: true
+                range: class1
+    class2:
+        is_a: class1
+        
+    mixin1a:
+        mixin: true
+        slot_usage:
+            slot2:
+                description: mixin slot2
+                required: false
+                range: mixin1a
+                
+    mixin1b:
+        mixin: true
+        slot_usage:
+            slot2:
+                description: mixin slot2
+                required: false
+                range: mixin1b
+                
+    class2_1a:
+        is_a: class2
+        mixins:
+          - mixin1a
+          - mixin1b
+          
+    class2_1b:
+        is_a: class2
+        mixins:
+          - mixin1b
+          - mixin1a
+          
+    class0:
+          
+slots:
+    slot1:
+        description: non-induced slot1
+        range: class0
+    slot2:
+        description: non-induced slot2
+        required: false
+        range: class0
+    slot3:
+        range: class0
+    
+"""
+
+
+class Issue68TestCase(TestCase):
+    """
+    Note: linkml-runtime issue 68 was moved to https://github.com/linkml/linkml/issues/479
+    """
+    def test_issue_68(self):
+        view = SchemaView(schema_str)
+
+        # test descripton for slot1
+        s1 = view.get_slot("slot1")
+        assert s1.description == "non-induced slot1"
+        assert s1.range == 'class0'
+
+        s2 = view.get_slot("slot2")
+        assert not s2.required
+        assert s2.range == 'class0'
+
+        s2_induced = view.induced_slot("slot2", "class1")
+        assert s2_induced.required
+        assert s2_induced.range == 'class1'
+
+        # test description for slot2
+        # this behavior is expected see: https://github.com/linkml/linkml-runtime/issues/68
+        logging.info(f"s2_induced.description: {s2_induced.description}")
+        assert s2_induced.description == "induced slot2"
+
+        s2_induced_c2 = view.induced_slot('slot2', 'class2')
+        assert s2_induced_c2.required
+        logging.info(f"s2_induced_c2.description: {s2_induced_c2.description}")
+        assert s2_induced_c2.description == "non-induced slot2"
+        assert s2_induced.range == 'class1'
+
+        s3_induced_c2 = view.induced_slot('slot3', 'class2')
+        assert not s3_induced_c2.required
+        assert s3_induced_c2.description == None
+        assert s3_induced_c2.range == 'class0'
+
+        # mixins take priority over is-a
+        # mixins specified in order of priority
+        s2_induced_c2_1a = view.induced_slot('slot2', 'class2_1a')
+        assert not s2_induced_c2_1a.required
+        logging.info(f"s2_induced_c2_1a.description: {s2_induced_c2_1a.description}")
+        assert s2_induced_c2_1a.description == "non-induced slot2"
+        assert s2_induced_c2_1a.range == 'mixin1a'
+
+        s2_induced_c2_1b = view.induced_slot('slot2', 'class2_1b')
+        assert not s2_induced_c2_1b.required
+        logging.info(f"s2_induced_c2_1a.description: {s2_induced_c2_1b.description}")
+        assert s2_induced_c2_1b.description == "non-induced slot2"
+        assert s2_induced_c2_1b.range == 'mixin1b'
+
+
+
+if __name__ == "__main__":
+    unittest.main()