Repository: linkml/linkml-runtime

Issue:
`SchemaView.materialize_patterns()` does not account for `slot_usage`
If I have a schema that contains the following:

```yaml
# test.yaml
settings:
  generated_id: "[a-z0-9]{6,}"

classes:
  MyClass:
    slots:
      - id

slots:
  id:
    structured_pattern:
      syntax: "prefix:{generated_id}"
```

And I call the `materialize_patterns` method on a `SchemaView` instance for that schema, I see that the `pattern` meta-slot is populated as expected:

```python
>>> from linkml_runtime import SchemaView
>>> view = SchemaView('test.yaml')
>>> view.schema.slots['id'].pattern is None
True
>>> view.materialize_patterns()
>>> view.schema.slots['id'].pattern
'prefix:[a-z0-9]{6,}'
```

However if the `structured_pattern` is instead set on the `slot_usage` of a class:

```yaml
# test2.yaml
settings:
  generated_id: "[a-z0-9]{6,}"

classes:
  MyClass:
    slots:
      -id
  FancyIdClass:
    is_a: MyClass
    slot_usage:
      id:
        structured_pattern:
          syntax: "prefix:{generated_id}"

slots:
  id:
```

Calling `materialize_patterns` on the `SchemaView` instance doesn't seem to have any effect. I suppose I would expect the `pattern` to be materialized on the `slot_usage` definition instead in this case.

```python
>>> view = SchemaView('test2.yaml')
>>> view.schema.classes['FancyIdClass'].slot_usage['id'].pattern is None
True
>>> view.materialize_patterns()
>>> # I think I would expect this to not be None at this point
>>> view.schema.classes['FancyIdClass'].slot_usage['id'].pattern is None
True
```




Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index 2c2b194..b84a58f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -140,3 +140,4 @@ Pipfile.lock
 
 # No Pycharm
 .idea/
+.vscode
diff --git a/linkml_runtime/utils/csvutils.py b/linkml_runtime/utils/csvutils.py
index 884f0b0..36fa11e 100644
--- a/linkml_runtime/utils/csvutils.py
+++ b/linkml_runtime/utils/csvutils.py
@@ -1,3 +1,4 @@
+import logging
 from json_flattener import KeyConfig, GlobalConfig, Serializer
 from json_flattener.flattener import CONFIGMAP
 from linkml_runtime.linkml_model.meta import SlotDefinitionName, SchemaDefinition, \
@@ -14,8 +15,11 @@ def get_configmap(schemaview: SchemaView, index_slot: SlotDefinitionName) -> CON
     :param index_slot: key that indexes the top level object
     :return: mapping between top level keys and denormalization configurations
     """
+    slot = None
     if index_slot is not None and schemaview is not None:
         slot = schemaview.get_slot(index_slot)
+
+    if slot is not None:
         if slot.range is not None and slot.range in schemaview.all_classes():
             cm = {}
             for sn in schemaview.class_slots(slot.range):
@@ -24,9 +28,9 @@ def get_configmap(schemaview: SchemaView, index_slot: SlotDefinitionName) -> CON
                     cm[sn] = config
             return cm
         else:
-            logging.warn(f'Index slot range not to class: {slot.range}')
+            logging.warning(f'Index slot range not to class: {slot.range}')
     else:
-        logging.warn(f'Index slot or schema not specified')
+        logging.warning(f'Index slot or schema not specified')
     return {}
 
 def _get_key_config(schemaview: SchemaView, tgt_cls: ClassDefinitionName, sn: SlotDefinitionName, sep='_'):
diff --git a/linkml_runtime/utils/pattern.py b/linkml_runtime/utils/pattern.py
index 9da2225..6045a2c 100644
--- a/linkml_runtime/utils/pattern.py
+++ b/linkml_runtime/utils/pattern.py
@@ -1,83 +1,88 @@
+from functools import lru_cache
 import re
 from typing import Dict
 
-
+# We might want to deprecate this method in favor of PatternResolver in the future
 def generate_patterns(schema_view) -> Dict[str, str]:
     """Generates a dictionary of slot patterns corresponding to
     the structured patterns in the settings.
-
     :param schema_view: SchemaView object with LinkML YAML
         already loaded
     :return generated_patterns: dictionary with the 
         expanded structured patterns 
     """
 
-    # fetch settings from schema_view
-    settings_dict = schema_view.schema.settings
-
-    # dictionary of key and string value of settings dict
-    format_spec = {}
-
-    for k, setting in settings_dict.items():
-
-        # create spec dictionary with keys that will replace
-        # substrings in the structured pattern syntax
-        format_spec[k] = setting.setting_value
+    resolver = PatternResolver(schema_view)
 
     # dictionary with structured patterns in the key and
     # expanded, or materialized patterns as values
     generated_patterns = {}
 
-    # regular expression capturing the various use cases
-    # for the optionally dot separated, curly braces bound, pattern syntax
-    var_name = re.compile("{([a-z0-9_-]+([\.-_ ][a-z0-9]+)*)}", re.IGNORECASE)
-
     for _, slot_defn in schema_view.all_slots().items():
         if slot_defn.structured_pattern:
             struct_pat = slot_defn.structured_pattern
-
             pattern = struct_pat.syntax
+            generated_patterns[pattern] = resolver.resolve(pattern)
 
-            # compute pattern from structured patterns
-            # and format_spec dictionary
-
-
-            # apply the regex to the pattern and look for matches
-            matches = var_name.finditer(pattern)
-
-            reversed = []
-            for item in matches:
-                # Detect double set brackets
-                match_string = None
-                if (
-                    item.start() > 0
-                    and item.end() < len(pattern)
-                    and pattern[item.start() - 1] == "{"
-                    and pattern[item.end()] == "}"
-                ):
-                    match_string = item.group(1)
-
-                elif item.group(1) in format_spec:
-                    match_string = str(format_spec[item.group(1)])
-
-                if match_string:
-                    reversed.insert(
-                        0,
-                        {
-                            "string": match_string,
-                            "start": item.start(),
-                            "end": item.end(),
-                        },
-                    )
-
-            converted = pattern
-            for item in reversed:
-                converted = (
-                    converted[: item["start"]]
-                    + item["string"]
-                    + converted[item["end"] :]
+    return generated_patterns
+
+
+class PatternResolver():
+
+    # regular expression capturing the various use cases
+    # for the optionally dot separated, curly braces bound, pattern syntax
+    var_name = re.compile("{([a-z0-9_-]+([\.-_ ][a-z0-9]+)*)}", re.IGNORECASE)
+
+    def __init__(self, schema_view):
+        # fetch settings from schema_view
+        settings_dict = schema_view.schema.settings
+
+        # dictionary of key and string value of settings dict
+        self.format_spec = {}
+
+        for k, setting in settings_dict.items():
+
+            # create spec dictionary with keys that will replace
+            # substrings in the structured pattern syntax
+            self.format_spec[k] = setting.setting_value
+
+    @lru_cache()
+    def resolve(self, pattern: str) -> str:
+        # apply the regex to the pattern and look for matches
+        matches = self.var_name.finditer(pattern)
+
+        reversed = []
+        for item in matches:
+            # Detect double set brackets
+            match_string = None
+            if (
+                item.start() > 0
+                and item.end() < len(pattern)
+                and pattern[item.start() - 1] == "{"
+                and pattern[item.end()] == "}"
+            ):
+                match_string = item.group(1)
+
+            elif item.group(1) in self.format_spec:
+                match_string = str(self.format_spec[item.group(1)])
+
+            if match_string:
+                reversed.insert(
+                    0,
+                    {
+                        "string": match_string,
+                        "start": item.start(),
+                        "end": item.end(),
+                    },
                 )
 
-            generated_patterns[pattern] = converted
+        converted = pattern
+        for item in reversed:
+            converted = (
+                converted[: item["start"]]
+                + item["string"]
+                + converted[item["end"] :]
+            )
+
+        return converted
 
-    return generated_patterns
diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py
index 4cd440e..7ce48f1 100644
--- a/linkml_runtime/utils/schemaview.py
+++ b/linkml_runtime/utils/schemaview.py
@@ -9,7 +9,7 @@ from typing import Mapping, Tuple, Type
 from linkml_runtime.utils.namespaces import Namespaces
 from deprecated.classic import deprecated
 from linkml_runtime.utils.context_utils import parse_import_map, map_import
-from linkml_runtime.utils.pattern import generate_patterns
+from linkml_runtime.utils.pattern import PatternResolver
 from linkml_runtime.linkml_model.meta import *
 from enum import Enum
 logger = logging.getLogger(__name__)
@@ -1485,12 +1485,22 @@ class SchemaView(object):
         into regular expressions based on composite patterns 
         provided in the settings dictionary.
         """
-        patterns_dict = generate_patterns(self)
-
-        for _, slot_defn in self.all_slots().items():
-            if slot_defn.structured_pattern:
-
-                pattern = slot_defn.structured_pattern.syntax
-
-                if pattern in patterns_dict:
-                    slot_defn.pattern = patterns_dict[pattern]
+        resolver = PatternResolver(self)
+
+        def materialize_pattern_into_slot_definition(slot_definition: SlotDefinition) -> None:
+            if not slot_definition.structured_pattern:
+                return
+            pattern = slot_definition.structured_pattern.syntax
+            slot_definition.pattern = resolver.resolve(pattern)
+
+        for slot_definition in self.all_slots().values():
+            materialize_pattern_into_slot_definition(slot_definition)
+
+        for class_definition in self.all_classes().values():
+            if class_definition.slot_usage:
+                for slot_definition in class_definition.slot_usage.values():
+                    materialize_pattern_into_slot_definition(slot_definition)
+        
+            if class_definition.attributes:
+                for slot_definition in class_definition.attributes.values():
+                    materialize_pattern_into_slot_definition(slot_definition)


Test patch:
diff --git a/tests/test_utils/input/pattern-example.yaml b/tests/test_utils/input/pattern-example.yaml
index 544f1ed..f40b1d8 100644
--- a/tests/test_utils/input/pattern-example.yaml
+++ b/tests/test_utils/input/pattern-example.yaml
@@ -32,6 +32,7 @@ settings:
   unit.length: "(centimeter|meter|inch)"
   unit.weight: "(kg|g|lbs|stone)"
   email: "\\S+@\\S+{\\.\\w}+"
+  hyphenated_name: "\\S+-\\S+"
   
 #==================================
 # Classes                         #
@@ -45,6 +46,23 @@ classes:
       - height
       - email
 
+  FancyPersonInfo:
+    is_a: PersonInfo
+    slot_usage:
+      name:
+        structured_pattern:
+          syntax: "\\S+ {hyphenated_name}"
+          interpolated: true
+          partial_match: false
+
+  ClassWithAttributes:
+    attributes:
+      weight:
+        structured_pattern:
+          syntax: "{float} {unit.weight}"
+          interpolated: true
+          partial_match: false
+
 #==================================
 # Slots                           #
 #==================================
diff --git a/tests/test_utils/test_pattern.py b/tests/test_utils/test_pattern.py
index 8c48e02..81df8c0 100644
--- a/tests/test_utils/test_pattern.py
+++ b/tests/test_utils/test_pattern.py
@@ -4,13 +4,12 @@ from tests.test_utils.environment import env
 
 from linkml_runtime.utils.schemaview import SchemaView
 
-from linkml_runtime.utils.pattern import generate_patterns
+from linkml_runtime.utils.pattern import PatternResolver, generate_patterns
 
 
 class PatternTestCase(unittest.TestCase):
     def test_generate_patterns(self):
         """Test method that consolidates composite patterns."""
-
         sv = SchemaView(env.input_path("pattern-example.yaml"))
 
         # actual result returned from call to generate_patterns()
@@ -24,5 +23,14 @@ class PatternTestCase(unittest.TestCase):
         self.assertDictEqual(actual_dict, expected_dict)
 
 
+    def test_pattern_resolver(self):
+        sv = SchemaView(env.input_path("pattern-example.yaml"))
+
+        resolver = PatternResolver(sv)
+
+        self.assertEqual(resolver.resolve("{float} {unit.length}"), "\\d+[\\.\\d+] (centimeter|meter|inch)")
+        self.assertEqual(resolver.resolve("{float} {unit.weight}"), "\\d+[\\.\\d+] (kg|g|lbs|stone)")
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py
index 3b3e175..9e24b7c 100644
--- a/tests/test_utils/test_schemaview.py
+++ b/tests/test_utils/test_schemaview.py
@@ -15,6 +15,7 @@ from tests.test_utils import INPUT_DIR
 
 SCHEMA_NO_IMPORTS = os.path.join(INPUT_DIR, 'kitchen_sink_noimports.yaml')
 SCHEMA_WITH_IMPORTS = os.path.join(INPUT_DIR, 'kitchen_sink.yaml')
+SCHEMA_WITH_STRUCTURED_PATTERNS = os.path.join(INPUT_DIR, "pattern-example.yaml")
 
 yaml_loader = YAMLLoader()
 
@@ -542,7 +543,7 @@ class SchemaViewTestCase(unittest.TestCase):
         self.assertListEqual(actual_result, expected_result)
 
     def test_materialize_patterns(self):
-        sv = SchemaView(os.path.join(INPUT_DIR, "pattern-example.yaml"))
+        sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS)
 
         sv.materialize_patterns()
 
@@ -552,6 +553,24 @@ class SchemaViewTestCase(unittest.TestCase):
         self.assertEqual(height_slot.pattern, "\d+[\.\d+] (centimeter|meter|inch)")
         self.assertEqual(weight_slot.pattern, "\d+[\.\d+] (kg|g|lbs|stone)")
 
+    def test_materialize_patterns_slot_usage(self):
+        sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS)
+
+        sv.materialize_patterns()
+
+        name_slot_usage = sv.get_class("FancyPersonInfo").slot_usage['name']
+
+        self.assertEqual(name_slot_usage.pattern, "\\S+ \\S+-\\S+")
+
+    def test_materialize_patterns_attribute(self):
+        sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS)
+
+        sv.materialize_patterns()
+
+        weight_attribute = sv.get_class('ClassWithAttributes').attributes['weight']
+
+        self.assertEqual(weight_attribute.pattern, "\d+[\.\d+] (kg|g|lbs|stone)")
+
 
 if __name__ == '__main__':
     unittest.main()