Repository: linkml/linkml

Issue:
Support multivalued slot constraints in JSON Schema generator
The LinkML model slots `all_members`, `has_member`, `minimum_cardinality`, and `maximum_cardinality` all have analogous concepts in JSON Schema, but currently `JsonSchemaGenerator` does not do anything with them. 

For example, this LinkML model:
```yaml
id: http://example.org/test
name: test

imports:
  - https://w3id.org/linkml/types

slots:
  s:
    range: integer
    multivalued: true
    has_member:
      minimum_value: 0
      maximum_value: 999

classes:
  C:
    tree_root: true
    slots:
      - s
```
should produce JSON Schema with a `contains` subschema:
```javascript
"properties": {
  "s": {
    "items": {
      "type": "integer"
    },
    // ↓↓↓ this is not included currently ↓↓↓
    "contains": {
      "minimum": 0,
      "maximum": 999
    },
    // ↑↑↑ ↑↑↑
    "type": "array"
  }
},
``` 

`minimum_cardinality` and `maximum_cardinality` should map directly to `minItems` and `maxItems` respectively (https://json-schema.org/understanding-json-schema/reference/array.html#length)

Comments:

Patch:
diff --git a/linkml/generators/jsonschemagen.py b/linkml/generators/jsonschemagen.py
index 7ed11914..3d21a670 100644
--- a/linkml/generators/jsonschemagen.py
+++ b/linkml/generators/jsonschemagen.py
@@ -87,14 +87,11 @@ class JsonSchema(UserDict):
             
             self['required'].append(canonical_name)
 
-    def add_keyword(self, keyword: str, value: Any, applies_to_all_array_elements: bool = False):
+    def add_keyword(self, keyword: str, value: Any):
         if value is None:
             return
         
-        if applies_to_all_array_elements and self.is_array:
-            self['items'][keyword] = value
-        else:
-            self[keyword] = value
+        self[keyword] = value
 
     @property
     def is_array(self):
@@ -241,12 +238,23 @@ class JsonSchemaGenerator(Generator):
                 rule_subschemas.append(inverse_subschema)
 
         if len(rule_subschemas) == 1:
-            self.top_level_schema.update(rule_subschemas[0])
+            class_subschema.update(rule_subschemas[0])
         elif len(rule_subschemas) > 1:
-            self.top_level_schema["allOf"] = rule_subschemas
+            if "allOf" not in class_subschema:
+                class_subschema["allOf"] = []
+            class_subschema["allOf"].extend(rule_subschemas)
 
         self.top_level_schema.add_def(cls.name, class_subschema)
 
+        if (
+            self.top_class is not None and camelcase(self.top_class) == camelcase(cls.name)
+        ) or (self.top_class is None and cls.tree_root):
+            for key, value in class_subschema.items():
+                # check this first to ensure we don't overwrite things like additionalProperties
+                # or description on the root. But we do want to copy over properties, required, 
+                # if, then, etc.
+                if key not in self.top_level_schema:
+                    self.top_level_schema[key] = value
 
     def get_subschema_for_anonymous_class(self, cls: AnonymousClassExpression, properties_required: bool = False) -> Union[None, JsonSchema]:
         if not cls:
@@ -320,6 +328,18 @@ class JsonSchemaGenerator(Generator):
             typ = "string"
 
         return (typ, fmt, reference)
+    
+    def get_value_constraints_for_slot(self, slot: Union[AnonymousSlotExpression, None]) -> JsonSchema:
+        if slot is None:
+            return JsonSchema()
+        
+        constraints = JsonSchema()
+        constraints.add_keyword('pattern', slot.pattern)
+        constraints.add_keyword('minimum', slot.minimum_value)
+        constraints.add_keyword('maximum', slot.maximum_value)
+        constraints.add_keyword('const', slot.equals_string)
+        constraints.add_keyword('const', slot.equals_number)
+        return constraints
 
     def get_subschema_for_slot(self, slot: SlotDefinition, omit_type: bool = False) -> JsonSchema:
         slot_has_range_union = slot.any_of is not None and len(slot.any_of) > 0 and all(s.range is not None for s in slot.any_of)
@@ -380,15 +400,19 @@ class JsonSchemaGenerator(Generator):
 
         prop.add_keyword('description', slot.description)
 
-        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)
+        own_constraints = self.get_value_constraints_for_slot(slot)
 
         if prop.is_array:
+            all_element_constraints = self.get_value_constraints_for_slot(slot.all_members)
+            any_element_constraints = self.get_value_constraints_for_slot(slot.has_member)
             prop.add_keyword('minItems', slot.minimum_cardinality)
             prop.add_keyword('maxItems', slot.maximum_cardinality)
+            prop["items"].update(own_constraints)
+            prop["items"].update(all_element_constraints)
+            if any_element_constraints:
+                prop["contains"] = any_element_constraints
+        else:
+            prop.update(own_constraints)
 
         if prop.is_object:
             prop.add_keyword('minProperties', slot.minimum_cardinality)
@@ -420,11 +444,6 @@ class JsonSchemaGenerator(Generator):
         prop = self.get_subschema_for_slot(slot)
         subschema.add_property(aliased_slot_name, prop, slot_is_required)
 
-        if (
-            self.top_class is not None and camelcase(self.top_class) == camelcase(cls.name)
-        ) or (self.top_class is None and cls.tree_root):
-            self.top_level_schema.add_property(aliased_slot_name, prop, slot_is_required)
-
     def serialize(self, **kwargs) -> str:
         self.start_schema()
         for enum_definition in self.schemaview.all_enums().values():
diff --git a/linkml/generators/pydanticgen.py b/linkml/generators/pydanticgen.py
index b685d45b..9171ab00 100644
--- a/linkml/generators/pydanticgen.py
+++ b/linkml/generators/pydanticgen.py
@@ -46,7 +46,8 @@ class ConfiguredBaseModel(WeakRefShimBaseModel,
                 validate_all = True,
                 underscore_attrs_are_private = True,
                 extra = {% if allow_extra %}'allow'{% else %}'forbid'{% endif %},
-                arbitrary_types_allowed = True):
+                arbitrary_types_allowed = True,
+                use_enum_values = True):
     pass
 
 {% for e in enums.values() %}


Test patch:
diff --git a/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml b/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml
index a4c0bd89..0eaddd7f 100644
--- a/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml
+++ b/tests/test_generators/input/jsonschema_multivalued_element_constraints.yaml
@@ -17,8 +17,31 @@ schema:
       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
+    int_list_with_all_members:
+      range: integer
+      multivalued: true
+      all_members:
+        minimum_value: 2
+        maximum_value: 5
+
+    int_list_with_has_member:
+      range: integer
+      multivalued: true
+      has_member:
+        minimum_value: 2
+        maximum_value: 5
+
+    string_list_with_all_members:
+      range: string
+      multivalued: true
+      all_members:
+        pattern: e.*
+
+    string_list_with_has_member:
+      range: string
+      multivalued: true
+      has_member:
+        pattern: e.*
 
   classes:
     Test:
@@ -26,6 +49,10 @@ schema:
       slots:
         - int_list
         - string_list
+        - int_list_with_all_members
+        - int_list_with_has_member
+        - string_list_with_all_members
+        - string_list_with_has_member
 json_schema:
   properties:
     int_list:
@@ -35,6 +62,20 @@ json_schema:
     string_list:
       items:
         pattern: e.*
+    int_list_with_all_members:
+      items:
+        minimum: 2
+        maximum: 5
+    int_list_with_has_member:
+      contains:
+        minimum: 2
+        maximum: 5
+    string_list_with_all_members:
+      items:
+        pattern: e.*
+    string_list_with_has_member:
+      contains:
+        pattern: e.*
 data_cases:
   - data: 
       int_list: [2, 3, 4, 5]
@@ -53,3 +94,37 @@ data_cases:
         - echo
         - foxtrot
     error_message: Failed validating 'pattern'
+  - data: 
+      int_list_with_all_members: [2, 3, 4, 5]
+  - data:
+      int_list_with_all_members: [1, 2, 3]
+    error_message: Failed validating 'minimum'
+  - data: 
+      int_list_with_has_member: [2, 3, 4, 5]
+  - data:
+      int_list_with_has_member: [0, 1, 2]
+  - data:
+      int_list_with_has_member: [6, 7, 8]
+    error_message: Failed validating 'contains'
+  - data:
+      string_list_with_all_members: 
+        - echo
+        - elephant
+  - data:
+      string_list_with_all_members: 
+        - echo
+        - foxtrot
+    error_message: Failed validating 'pattern'
+  - data:
+      string_list_with_has_member: 
+        - echo
+        - elephant
+  - data:
+      string_list_with_has_member: 
+        - echo
+        - foxtrot
+  - data:
+      string_list_with_has_member: 
+        - foxtrot
+        - golf
+    error_message: Failed validating 'contains'
diff --git a/tests/test_generators/input/jsonschema_rules_in_non_root_class.yaml b/tests/test_generators/input/jsonschema_rules_in_non_root_class.yaml
new file mode 100644
index 00000000..b6abe7b6
--- /dev/null
+++ b/tests/test_generators/input/jsonschema_rules_in_non_root_class.yaml
@@ -0,0 +1,69 @@
+schema:
+  name: test_rules_in_non_root_class
+  id: http://example.org/test_rules_in_non_root_class
+  prefixes:
+    linkml: https://w3id.org/linkml/
+  imports:
+    - linkml:types
+  slots:
+    addresses:
+      range: Address
+      multivalued: true
+      inlined: true
+      inlined_as_list: true
+    street_address:
+    country:
+    postal_code:
+    telephone:
+
+  classes:
+    Address:
+      slots:
+        - street_address
+        - country
+        - postal_code
+        - telephone
+      rules:
+        - preconditions:
+            slot_conditions:
+              country:
+                any_of:
+                  - equals_string: USA
+                  - equals_string: USA_territory
+          postconditions:
+            slot_conditions:
+              postal_code:
+                pattern: "[0-9]{5}(-[0-9]{4})?"
+              telephone:
+                pattern: "^\\+1 "
+    AddressCollection:
+      slots:
+        - addresses
+      tree_root: true
+
+data_cases:
+  - data: 
+      addresses:
+        - street_address: 123 main street
+          country: USA
+          postal_code: "12345"
+          telephone: "+1 555 555 1234"
+  - data: 
+      addresses:
+        - street_address: 123 main street
+          country: USA_territory
+          postal_code: "12345"
+          telephone: "+1 555 555 1234"
+  - data: 
+      addresses:
+        - street_address: 123 main street
+          country: USA
+          postal_code: "DK-1448"
+          telephone: "+1 555 555 1234"
+    error_message: "'DK-1448' does not match"
+  - data: 
+      addresses:
+        - street_address: Asiatisk Plads 2
+          country: DEN
+          postal_code: "DK-1448"
+          telephone: "+45 5555 5555"
diff --git a/tests/test_generators/test_jsonschemagen.py b/tests/test_generators/test_jsonschemagen.py
index 463733e9..5c1100fd 100644
--- a/tests/test_generators/test_jsonschemagen.py
+++ b/tests/test_generators/test_jsonschemagen.py
@@ -201,6 +201,11 @@ class JsonSchemaTestCase(unittest.TestCase):
                     schema, case["json_schema"], case.get("data_cases", [])
                 )
 
+    def test_rules_in_non_root_class(self):
+        """Tests that rules are applied to slots in non-root classes. """
+
+        self.externalFileTest("jsonschema_rules_in_non_root_class.yaml")
+
     def test_range_unions(self):
         """Tests various permutations of range unions.
 
diff --git a/tests/test_issues/test_linkml_issue_723.py b/tests/test_issues/test_linkml_issue_723.py
index db6db433..a4927b0a 100644
--- a/tests/test_issues/test_linkml_issue_723.py
+++ b/tests/test_issues/test_linkml_issue_723.py
@@ -277,7 +277,6 @@ class Issue723ExportCase(TestEnvironmentTestCase):
         p3.roles = [mod.Role.ANALYST, mod.Role.INVESTIGATOR]
         self.assertEqual(p, p3)
         self.assertEqual(p.status, mod.VitalStatus.ALIVE)
-        self.assertEqual(type(p.status), mod.VitalStatus)
         self.assertEqual(p.roles, [mod.Role.ANALYST, mod.Role.INVESTIGATOR])
         # test the "double wrap" code
         p.status = mod.VitalStatus(mod.VitalStatus.ALIVE)