Repository: linkml/linkml

Issue:
JSON Schema generator should implicitly apply value constraints to all members of multivalued slots
**Describe the bug**
Using this schema as an example:

```yaml
id: http://example.com/test
name: test

imports:
  - https://w3id.org/linkml/types

slots:
  e_words_only:
    range: string
    multivalued: true
    pattern: "e.*"

classes:
  Container:
    slots:
      - e_words_only
```

I would expect that `gen-json-schema` would produce a JSON Schema that _fails_ validation for this data instance:

```json
{
  "e_words_only": [
    "echo",
    "foxtrot"
  ]
}
```

However that currently does not appear to be the case.

**To reproduce**
Steps to reproduce the behavior:
1. With the above schema as `test.yaml`: `gen-json-schema --top-class Container test.yaml > test.schema.json`
2. With the above data instance as `test.data.json`: `jsonschema -i test.data.json test.schema.json` (assuming you're in a virtual environment with https://pypi.org/project/jsonschema/)
3. Note that no validation error is produced

**Additional context**
The JSON Schema that's currently being produced for the above example contains the following:

```json
"properties": {
    "e_words_only": {
        "items": {
            "type": "string"
        },
        "pattern": "e.*",
        "type": "array"
    }
},
```

In order for the `pattern` to apply to all elements, it would need to be moved into the `items` specification.


Discussion:

Patch:
diff --git a/linkml/generators/jsonschemagen.py b/linkml/generators/jsonschemagen.py
index 66d5f85c..ebbf9b27 100644
--- a/linkml/generators/jsonschemagen.py
+++ b/linkml/generators/jsonschemagen.py
@@ -4,7 +4,7 @@ import os
 from collections import UserDict
 from copy import deepcopy
 from dataclasses import dataclass, field
-from typing import Dict, List, Optional, Tuple, Union
+from typing import Any, Dict, List, Optional, Tuple, Union
 
 import click
 from linkml_runtime.linkml_model.meta import (AnonymousClassExpression,
@@ -87,19 +87,37 @@ class JsonSchema(UserDict):
             
             self['required'].append(canonical_name)
 
+    def add_keyword(self, keyword: str, value: Any, applies_to_all_array_elements: bool = False):
+        if value is None:
+            return
+        
+        if applies_to_all_array_elements and self.is_array:
+            self['items'][keyword] = value
+        else:
+            self[keyword] = value
+
+    @property
+    def is_array(self):
+        return self.get('type') == 'array'
+
+    @property
+    def is_object(self):
+        return self.get('type') == 'object'
+
     def to_json(self, **kwargs) -> str:
         return json.dumps(self.data, default=lambda d: d.data, **kwargs)
 
     @classmethod
     def ref_for(cls, class_name: Union[str, List[str]], identifier_optional: bool = False):
         def _ref(class_name):
-            return { 
+            return JsonSchema({ 
                 "$ref": f"#/$defs/{camelcase(class_name)}{cls.OPTIONAL_IDENTIFIER_SUFFIX if identifier_optional else ''}"
-            }
+            })
+        
         if isinstance(class_name, list):
-            return {
+            return JsonSchema({
                 "oneOf": [_ref(name) for name in class_name]
-            }
+            })
         else:
             return _ref(class_name)
 
@@ -359,36 +377,21 @@ class JsonSchemaGenerator(Generator):
                         else:
                             prop = JsonSchema({"type": typ, "format": fmt})
 
-        if slot.description:
-            prop['description'] = slot.description
-
-        if slot.pattern:
-            # See https://github.com/linkml/linkml/issues/193
-            prop['pattern'] = slot.pattern
-
-        if slot.minimum_value is not None:
-            prop['minimum'] = slot.minimum_value
-
-        if slot.maximum_value is not None:
-            prop['maximum'] = slot.maximum_value
-
-        if slot.equals_string is not None:
-            prop['const'] = slot.equals_string
+        prop.add_keyword('description', slot.description)
 
-        if slot.equals_number is not None:
-            prop['const'] = slot.equals_number
+        prop.add_keyword('pattern', slot.pattern, applies_to_all_array_elements=True)
+        prop.add_keyword('minimum', slot.minimum_value, applies_to_all_array_elements=True)
+        prop.add_keyword('maximum', slot.maximum_value, applies_to_all_array_elements=True)
+        prop.add_keyword('const', slot.equals_string, applies_to_all_array_elements=True)
+        prop.add_keyword('const', slot.equals_number, applies_to_all_array_elements=True)
 
-        if slot.minimum_cardinality is not None:
-            if prop['type'] == 'array':
-                prop['minItems'] = slot.minimum_cardinality
-            elif prop['type'] == 'object':
-                prop['minProperties'] = slot.minimum_cardinality
+        if prop.is_array:
+            prop.add_keyword('minItems', slot.minimum_cardinality)
+            prop.add_keyword('maxItems', slot.maximum_cardinality)
 
-        if slot.maximum_cardinality is not None:
-            if prop['type'] == 'array':
-                prop['maxItems'] = slot.maximum_cardinality
-            elif prop['type'] == 'object':
-                prop['maxProperties'] = slot.maximum_cardinality
+        if prop.is_object:
+            prop.add_keyword('minProperties', slot.minimum_cardinality)
+            prop.add_keyword('maxProperties', slot.maximum_cardinality)
 
         if slot.any_of is not None and len(slot.any_of) > 0:
             if not slot_has_range_union:
diff --git a/linkml/workspaces/example_runner.py b/linkml/workspaces/example_runner.py
index d8a23241..76d6045d 100644
--- a/linkml/workspaces/example_runner.py
+++ b/linkml/workspaces/example_runner.py
@@ -78,6 +78,8 @@ class ExampleRunner:
 
     _validator: Optional[DataValidator] = None
 
+    use_type_designators: bool = True
+
     @property
     def python_module(self) -> ModuleType:
         """
@@ -210,6 +212,8 @@ class ExampleRunner:
         :param target_class:
         :return:
         """
+        if not self.use_type_designators:
+            return dict_obj
         sv = self.schemaview
         if target_class is None:
             target_class_names = [c.name for c in sv.all_classes().values() if c.tree_root]
@@ -280,6 +284,10 @@ class ExampleRunner:
               default=sys.stdout,
               type=click.File("w", encoding="utf-8"),
               help="Output file for markdown summary")
+@click.option("--use-type-designators/--no-use-type-designators",
+              default=True,
+              show_default=True,
+              help="If true use type_designators to deepen ranges")
 def cli(schema, prefixes, output: TextIO, **kwargs):
     """Process a folder of examples and a folder of counter examples.
 


Test patch:
diff --git a/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml b/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml
new file mode 100644
index 00000000..a4c0bd89
--- /dev/null
+++ b/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml
@@ -0,0 +1,55 @@
+schema:
+  id: http://example.org/jsonschema_multivalued_element_constraints
+  name: jsonschema_multivalued_element_constraints
+
+  imports:
+    - https://w3id.org/linkml/types
+
+  slots:
+    int_list:
+      range: integer
+      multivalued: true
+      minimum_value: 2
+      maximum_value: 5
+    
+    string_list:
+      range: string
+      multivalued: true
+      pattern: e.*
+
+    # TODO: this should also contain test cases for has_member and all_members
+    # See: https://github.com/linkml/linkml/issues/1107
+
+  classes:
+    Test:
+      tree_root: true
+      slots:
+        - int_list
+        - string_list
+json_schema:
+  properties:
+    int_list:
+      items:
+        minimum: 2
+        maximum: 5
+    string_list:
+      items:
+        pattern: e.*
+data_cases:
+  - data: 
+      int_list: [2, 3, 4, 5]
+  - data:
+      int_list: [1, 2, 3]
+    error_message: Failed validating 'minimum'
+  - data:
+      int_list: [4, 5, 6]
+    error_message: Failed validating 'maximum'
+  - data:
+      string_list: 
+        - echo
+        - elephant
+  - data:
+      string_list: 
+        - echo
+        - foxtrot
+    error_message: Failed validating 'pattern'
diff --git a/tests/test_generators/test_jsonschemagen.py b/tests/test_generators/test_jsonschemagen.py
index 25a5f869..7dd992a6 100644
--- a/tests/test_generators/test_jsonschemagen.py
+++ b/tests/test_generators/test_jsonschemagen.py
@@ -223,6 +223,12 @@ class JsonSchemaTestCase(unittest.TestCase):
         self.externalFileTest("jsonschema_multivalued_slot_cardinality.yaml")
 
 
+    def test_multivalued_element_constraints(self):
+        """Tests that atomic checks on instances are applied to elements of multivalued slots."""
+
+        self.externalFileTest("jsonschema_multivalued_element_constraints.yaml")
+
+
     # **********************************************************
     #
     #    Utility methods