Repository: linkml/linkml

Issue:
Add `--include-range-class-descendants` option to "linkml-validate"
**Is your feature request related to a problem? Please describe.**

`linkml-validate` doesn't accept children of a class specified in a range as valid. Being used to the behavior of inheritance in OOP that also applies to the generated Python code, I find it misleading and frustrating.

**Describe the solution you'd like**
IMO (point of view of an OOP programmer, might be different for an "ontologist") accepting children should be the default behavior. Since `gen-json-schema` already has the option `--include-range-class-descendants`, having such an option for `linkml-validate` would be also fine for me.

**How important is this feature?** Select from the options below:
• Medium - As of now I have to ignore validation errors that appear due to this limitation of `linkml-validate`

**When will use cases depending on this become relevant?** Select from the options below:
• Short-term - 2-4 weeks

**Additional context**
I'm providing a PR to implement this feature.

Comments:

Patch:
diff --git a/linkml/validators/jsonschemavalidator.py b/linkml/validators/jsonschemavalidator.py
index 551db8a2..039ca65a 100644
--- a/linkml/validators/jsonschemavalidator.py
+++ b/linkml/validators/jsonschemavalidator.py
@@ -26,7 +26,7 @@ class HashableSchemaDefinition(SchemaDefinition):
 
 
 @lru_cache(maxsize=None)
-def _generate_jsonschema(schema, top_class, closed):
+def _generate_jsonschema(schema, top_class, closed, include_range_class_descendants):
     logging.debug("Generating JSON Schema")
     not_closed = not closed
     return JsonSchemaGenerator(
@@ -34,6 +34,7 @@ def _generate_jsonschema(schema, top_class, closed):
         mergeimports=True,
         top_class=top_class,
         not_closed=not_closed,
+        include_range_class_descendants=include_range_class_descendants,
     ).generate()
 
 
@@ -49,6 +50,7 @@ class JsonSchemaDataValidator(DataValidator):
     Implementation of DataValidator that wraps jsonschema validation
     """
 
+    include_range_class_descendants: bool = False
     _hashable_schema: Union[str, HashableSchemaDefinition] = field(init=False, repr=False)
 
     def __setattr__(self, __name: str, __value: Any) -> None:
@@ -104,7 +106,9 @@ class JsonSchemaDataValidator(DataValidator):
             if len(roots) != 1:
                 raise ValueError(f"Cannot determine tree root: {roots}")
             target_class_name = roots[0]
-        jsonschema_obj = _generate_jsonschema(self._hashable_schema, target_class_name, closed)
+        jsonschema_obj = _generate_jsonschema(
+            self._hashable_schema, target_class_name, closed, self.include_range_class_descendants
+        )
         validator = jsonschema.Draft7Validator(
             jsonschema_obj, format_checker=jsonschema.Draft7Validator.FORMAT_CHECKER
         )
@@ -135,6 +139,14 @@ class JsonSchemaDataValidator(DataValidator):
     default=False,
     help="Exit after the first validation failure is found. If not specified all validation failures are reported.",
 )
+@click.option(
+    "--include-range-class-descendants/--no-range-class-descendants",
+    default=False,
+    show_default=False,
+    help="""
+When handling range constraints, include all descendants of the range class instead of just the range class
+""",
+)
 @click.argument("input")
 @click.version_option(__version__, "-V", "--version")
 def cli(
@@ -145,6 +157,7 @@ def cli(
     schema=None,
     index_slot=None,
     exit_on_first_failure=False,
+    include_range_class_descendants=False,
 ) -> None:
     """
     Validates instance data
@@ -188,7 +201,9 @@ def cli(
     if schema is None:
         raise Exception("--schema must be passed in order to validate. Suppress with --no-validate")
 
-    validator = JsonSchemaDataValidator(schema)
+    validator = JsonSchemaDataValidator(
+        schema, include_range_class_descendants=include_range_class_descendants
+    )
     error_count = 0
     for error in validator.iter_validate_dict(
         data_as_dict, target_class_name=py_target_class.class_name


Test patch:
diff --git a/tests/test_validation/input/Person-02.yaml b/tests/test_validation/input/Person-02.yaml
new file mode 100644
index 00000000..6aa60c50
--- /dev/null
+++ b/tests/test_validation/input/Person-02.yaml
@@ -0,0 +1,15 @@
+id: P:004
+name: eventful life
+has_events:
+  - employed_at: ROR:1
+    started_at_time: "2019-01-01"
+    is_current: true
+  - started_at_time: "2023-01-01"
+    in_location: GEO:1234
+    diagnosis:
+      id: CODE:P1789
+      name: hypertension
+    procedure:
+      id: CODE:P1846
+      name: valve repair
+
diff --git a/tests/test_validation/input/kitchen_sink.yaml b/tests/test_validation/input/kitchen_sink.yaml
index ea0b5b7b..ac7aaeb5 100644
--- a/tests/test_validation/input/kitchen_sink.yaml
+++ b/tests/test_validation/input/kitchen_sink.yaml
@@ -74,6 +74,7 @@ classes:
       - age in years
       - addresses
       - has birth event
+      - has events
     slot_usage:
       name:
         pattern: "^\\S+ \\S+"  ## do not do this in a real schema, people have all kinds of names
@@ -240,6 +241,10 @@ slots:
   city:
   has birth event:
     range: BirthEvent
+  has events:
+    multivalued: True
+    range: Event
+    inlined_as_list: true
 
 enums:
   FamilialRelationshipType:
diff --git a/tests/test_validation/test_jsonschemavalidation.py b/tests/test_validation/test_jsonschemavalidation.py
index 30981d22..a7968595 100644
--- a/tests/test_validation/test_jsonschemavalidation.py
+++ b/tests/test_validation/test_jsonschemavalidation.py
@@ -16,6 +16,7 @@ SCHEMA = env.input_path("kitchen_sink.yaml")
 DATASET_1 = env.input_path("Dataset-01.yaml")
 DATASET_2 = env.input_path("Dataset-02.yaml")
 PERSON_1 = env.input_path("Person-01.yaml")
+PERSON_2 = env.input_path("Person-02.yaml")
 PERSON_INVALID_1 = env.input_path("Person-invalid-01.yaml")
 
 
@@ -48,6 +49,35 @@ class JsonSchemaValidatorTestCase(unittest.TestCase):
         result = validator.validate_dict(obj, "Person")
         self.assertIsNone(result)
 
+        with open(PERSON_2) as file:
+            obj = yaml.safe_load(file)
+
+        with self.assertRaises(JsonSchemaDataValidatorError) as ctx:
+            validator.validate_dict(obj, "Person")
+
+        with open(PERSON_INVALID_1) as file:
+            obj = yaml.safe_load(file)
+
+        with self.assertRaises(JsonSchemaDataValidatorError) as ctx:
+            validator.validate_dict(obj, "Person")
+
+        messages = ctx.exception.validation_messages
+        self.assertEqual(len(messages), 1)
+        self.assertIn("name", messages[0])
+
+    def test_validate_dict_including_descendants(self):
+        validator = JsonSchemaDataValidator(schema=SCHEMA, include_range_class_descendants=True)
+
+        with open(PERSON_1) as file:
+            obj = yaml.safe_load(file)
+        result = validator.validate_dict(obj, "Person")
+        self.assertIsNone(result)
+
+        with open(PERSON_2) as file:
+            obj = yaml.safe_load(file)
+        result = validator.validate_dict(obj, "Person")
+        self.assertIsNone(result)
+
         with open(PERSON_INVALID_1) as file:
             obj = yaml.safe_load(file)