Repository: linkml/linkml

Issue:
Any interest in a Golang generator?
**Is your feature request related to a problem? Please describe.**
As a developer on a KBase-JGI co-development project, I'm working on a data transfer microservice in Go that incorporates some data types from KBase's Credit Engine. The Credit Engine articulates these types in a LinkML schema, and so it would be natural for me to generate Go code using this schema.

**Describe the solution you'd like**
I'm interested in adding a Golang generator to LinkML, and willing to do the work if I can ask some questions related to the generators that may not be handled in [your relevant documentation](https://linkml.io/linkml/howtos/port-linkml.html). I'm sure this effort can be justified in the context of the co-development project, since it's part of a greater interoperability strategy being pursued by KBase and JGI.

**How important is this feature?** Select from the options below:
• Medium - can do work without it; but it's important (e.g. to save time or for convenience)
The number of types I need for this project is small, so I can certainly write the code myself by inspecting the LinkML schema. But as we all know, maintenance is the elephant in the software room, and it would be nice to have a workflow that simplified updates to the KBase Credit Engine schema (and/or updates to LinkML and its schema features).

**When will use cases depending on this become relevant?** Select from the options below:
• Mid-term - 2-4 months
Again, the LinkML generator is for ease of maintenance, so my need is not urgent. But I do have some time in the short term to throw at this if there's no opposition from the project.

@ialarmedalien 

Comments:

Patch:
diff --git a/linkml/generators/README.md b/linkml/generators/README.md
index d518b47b..af044001 100644
--- a/linkml/generators/README.md
+++ b/linkml/generators/README.md
@@ -6,6 +6,7 @@
 |  gen-jsonld-context   | [jsonldcontextgen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/jsonldcontextgen.py) |    ContextGenerator | generate a JSON-LD @context block  | [contextgen help](../../tests/test_scripts/output/gencontext/help) |
 |  gen-csv   | [csvgen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/csvgen.py) |    CsvGenerator | generate a csv summary  | [csvgen help](../../tests/test_scripts/output/gencsv/help) |
 |  gen-graphviz   | [dotgen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/dotgen.py) |    DotGenerator | generate graphviz representation  | [dotgen help](../../tests/test_scripts/output/gengraphviz/help) |
+|  gen-golang   | [golanggen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/golanggen.py) |    GolangGenerator | generate Golang representation  | [golanggen help](../../tests/test_scripts/output/gengolang/help) |
 |  gen-golr-views   | [golrgen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/golrgen.py) |    GolrSchemaGenerator | generate a GOLR(?) representation  | [golrgen help](../../tests/test_scripts/output/genglor/help) |
 |  gen-graphql   | [graphqlgen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/graphqlgen.py) |    GraphqlGenerator | generate a graphql representation  | [graphql help](../../tests/test_scripts/output/gengraphql/help) |
 |  gen-proto  | [protogen.py](https://github.com/linkml/linkml/blob/main/linkml/generators/protogen.py) | ProtoGenerator | generate Protobuf Schema representation | [proto help](../../tests/test_scripts/output/genproto/help) |
diff --git a/linkml/generators/golanggen.py b/linkml/generators/golanggen.py
new file mode 100644
index 00000000..2f87a761
--- /dev/null
+++ b/linkml/generators/golanggen.py
@@ -0,0 +1,216 @@
+import logging
+import os
+from copy import deepcopy
+from dataclasses import dataclass
+from pathlib import Path
+from typing import (Callable, Dict, Iterator, List, Optional, Set, TextIO,
+                    Tuple, Union)
+
+import click
+from jinja2 import Environment, FileSystemLoader, Template
+from linkml_runtime.dumpers import yaml_dumper
+from linkml_runtime.linkml_model.meta import (Annotation, ClassDefinition,
+                                              ClassDefinitionName, Definition,
+                                              DefinitionName, Element,
+                                              EnumDefinition, SchemaDefinition,
+                                              SlotDefinition,
+                                              SlotDefinitionName,
+                                              TypeDefinition)
+from linkml_runtime.utils.formatutils import camelcase, underscore
+from linkml_runtime.utils.schemaview import SchemaView
+
+from linkml._version import __version__
+from linkml.utils.generator import Generator, shared_arguments
+
+type_map = {
+    "str": "string",
+    "int": "int",
+    "Bool": "bool",
+    "float": "float64",
+    "XSDDate": "time.Date",
+}
+
+default_template = """
+{%- if '_' in view.schema.name -%}
+    {%- set package_name = view.schema.name[:view.schema.name.find('_')] -%}
+{%- else -%}
+    {%- set package_name = view.schema.name -%}
+{%- endif -%}
+package {{package_name}}
+
+{% for c in view.all_classes().values() -%}
+    {%- for sn in view.class_slots(c.name, direct=False) %}
+        {%- set s = view.induced_slot(sn, c.name) -%}
+        {%- if "time." in gen.range(s) -%}
+            {%- set usesTime = True %}
+        {%- else -%}
+            {%- set usesTime = False %}
+        {%- endif -%}
+    {%- endfor -%}
+{%- endfor -%}
+{%- if usesTime -%}
+import (
+    "time" // for time.Date
+)
+{%- endif -%}
+
+{% for c in view.all_classes().values() -%}
+{%- if c.description -%}
+/*
+ * {{c.description}}
+ */
+{%- endif -%}
+{% set parents = gen.parents(c) %}
+type {{gen.name(c)}} struct {
+    {%- if parents %}
+	/*
+	 * parent types
+	 */
+    {%- for p in parents %}
+	{{p}}
+    {%- endfor %}
+    {%- endif -%}
+    {%- for sn in view.class_slots(c.name, direct=False) %}
+    {%- set s = view.induced_slot(sn, c.name) -%}
+    {%- if s.description %}
+	/*
+	 * {{s.description}}
+	 */
+    {%- endif %}
+	{{gen.name(s)}} {{gen.range(s)}} `json:"{{gen.json_name(s)}}"`
+    {%- endfor %}
+}
+
+{% endfor %}
+"""
+
+
+@dataclass
+class GolangGenerator(Generator):
+    """
+    Generates Golang code from a schema
+    """
+
+    # ClassVars
+    generatorname = os.path.basename(__file__)
+    generatorversion = "0.1.0"
+    valid_formats = ["text"]
+    uses_schemaloader = False
+
+    def serialize(self) -> str:
+        """
+        Serialize a schema to Golang string
+        :return:
+        """
+        template_obj = Template(default_template)
+        out_str = template_obj.render(
+            gen=self, schema=self.schemaview.schema, view=self.schemaview
+        )
+        return out_str
+
+    def name(self, element: Element) -> str:
+        """
+        Returns the name of the element in its canonical form
+
+        :param element:
+        :return:
+        """
+        alias = element.name
+        if isinstance(element, SlotDefinition) and element.alias:
+            alias = element.alias
+        return camelcase(alias)
+
+    def json_name(self, element: Element) -> str:
+        """
+        Returns the name of the element in its JSON (snake-case) form
+
+        :param element:
+        :return:
+        """
+        alias = element.name
+        if isinstance(element, SlotDefinition) and element.alias:
+            alias = element.alias
+        return underscore(alias)
+
+    def classref(self, cls: ClassDefinition) -> Optional[str]:
+        """
+        Returns the class name for the class that holds a reference (foreign key) to members of this class
+
+        E.g. if a class Person has an identifier field called unique_id, then this will
+        return PersonUniqueId
+
+        :param cls:
+        :return: ref name, None if no identifier
+        """
+        id_slot = self.get_identifier_or_key_slot(cls.name)
+        if id_slot:
+            return f"{self.name(cls)}{camelcase(id_slot.name)}"
+        else:
+            return None
+
+    def get_identifier_or_key_slot(
+        self, cn: ClassDefinitionName
+    ) -> Optional[SlotDefinition]:
+        sv = self.schemaview
+        id_slot = sv.get_identifier_slot(cn)
+        if id_slot:
+            return id_slot
+        else:
+            for s in sv.class_induced_slots(cn):
+                if s.key:
+                    return s
+            return None
+
+    def range(self, slot: SlotDefinition) -> str:
+        sv = self.schemaview
+        r = slot.range
+        if r in sv.all_classes():
+            rc = sv.get_class(r)
+            rc_ref = self.classref(rc)
+            rc_name = self.name(rc)
+            id_slot = self.get_identifier_or_key_slot(r)
+            if slot.multivalued:
+                if not id_slot or slot.inlined:
+                    if slot.inlined_as_list or not id_slot:
+                        return f"[]{rc_name}"
+                    else:
+                        return f"[]{rc_name}"
+                else:
+                    return f"{rc_ref}[]"
+            else:
+                if not id_slot or slot.inlined:
+                    return rc_name
+                else:
+                    return f"{rc_ref}"
+        else:
+            if r in sv.all_types():
+                t = sv.get_type(r)
+                if t.base and t.base in type_map:
+                    return type_map[t.base]
+                else:
+                    logging.warning(f"Unknown type.base: {t.name}")
+            return "string"
+
+    def parents(self, cls: ClassDefinition) -> List[ClassDefinitionName]:
+        if cls.is_a:
+            parents = [cls.is_a]
+        else:
+            parents = []
+        return [ClassDefinitionName(camelcase(p)) for p in parents + cls.mixins]
+
+
+@shared_arguments(GolangGenerator)
+@click.version_option(__version__, "-V", "--version")
+@click.command()
+def cli(yamlfile, **args):
+    """Generate Golang types
+
+    This very simple generator produces a Golang package named after the given
+    schema with structs that implement the classes in that schema.
+    """
+    gen = GolangGenerator(yamlfile, **args)
+    print(gen.serialize())
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/linkml/validators/jsonschemavalidator.py b/linkml/validators/jsonschemavalidator.py
index 96c585e1..74f74c17 100644
--- a/linkml/validators/jsonschemavalidator.py
+++ b/linkml/validators/jsonschemavalidator.py
@@ -52,7 +52,8 @@ class JsonSchemaDataValidator(DataValidator):
         if self.jsonschema_objs is None:
             self.jsonschema_objs = {}
         schema_id = self.schema.id if isinstance(self.schema, SchemaDefinition) else self.schema
-        if schema_id not in self.jsonschema_objs:
+        cache_params = frozenset([schema_id, target_class.class_name])
+        if cache_params not in self.jsonschema_objs:
             jsonschemastr = JsonSchemaGenerator(
                 self.schema,
                 mergeimports=True,
@@ -60,10 +61,10 @@ class JsonSchemaDataValidator(DataValidator):
                 not_closed=not_closed,
             ).serialize(not_closed=not_closed)
             jsonschema_obj = json.loads(jsonschemastr)
-            self.jsonschema_objs[schema_id] = jsonschema_obj
+            self.jsonschema_objs[cache_params] = jsonschema_obj
         else:
             logging.info(f"Using cached jsonschema for {schema_id}")
-            jsonschema_obj = self.jsonschema_objs[schema_id]
+            jsonschema_obj = self.jsonschema_objs[cache_params]
         return jsonschema.validate(inst_dict, schema=jsonschema_obj, format_checker=jsonschema.Draft7Validator.FORMAT_CHECKER)
 
     def validate_dict(
@@ -148,7 +149,7 @@ def cli(
     outargs = {}
     if datautils._is_xsv(input_format):
         if index_slot is None:
-            index_slot = infer_index_slot(sv, target_class)
+            index_slot = datautils.infer_index_slot(sv, target_class)
             if index_slot is None:
                 raise Exception("--index-slot is required for CSV input")
         inargs["index_slot"] = index_slot
diff --git a/pyproject.toml b/pyproject.toml
index 7077b38c..10b4ef33 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,7 @@ gen-jsonld-context = "linkml.generators.jsonldcontextgen:cli"
 gen-prefix-map = "linkml.generators.prefixmapgen:cli"
 gen-csv = "linkml.generators.csvgen:cli"
 gen-graphviz = "linkml.generators.dotgen:cli"
+gen-golang = "linkml.generators.golanggen:cli"
 gen-golr-views = "linkml.generators.golrgen:cli"
 gen-graphql = "linkml.generators.graphqlgen:cli"
 gen-java = "linkml.generators.javagen:cli"


Test patch:
diff --git a/tests/test_generators/test_golanggen.py b/tests/test_generators/test_golanggen.py
new file mode 100644
index 00000000..a5510f21
--- /dev/null
+++ b/tests/test_generators/test_golanggen.py
@@ -0,0 +1,28 @@
+import sys
+import unittest
+
+from linkml.generators.golanggen import GolangGenerator
+from tests.test_generators.environment import env
+
+SCHEMA = env.input_path("kitchen_sink.yaml")
+OUT = env.expected_path("kitchen_sink.go")
+
+
+class GolangGeneratorTestCase(unittest.TestCase):
+    def test_golanggen(self):
+        """typescript"""
+        code = GolangGenerator(SCHEMA, mergeimports=True).serialize()
+        with open(OUT, "w") as stream:
+            stream.write(code)
+
+        def assert_in(s: str) -> None:
+            assert s.replace(" ", "") in code.replace(" ", "")
+
+        assert "package kitchen" in code
+        assert_in("type Person struct {")
+        assert_in("HasFamilialRelationships []FamilialRelationship")
+        assert_in("CodeSystems []CodeSystem")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_validation/input/kitchen_sink_inst_02.yaml b/tests/test_validation/input/kitchen_sink_inst_02.yaml
new file mode 100644
index 00000000..cee57ba9
--- /dev/null
+++ b/tests/test_validation/input/kitchen_sink_inst_02.yaml
@@ -0,0 +1,32 @@
+persons:
+  - id: P:003
+    name: hermoine granger
+    age_in_years: 33
+  - id: P:004
+    name: harry potter
+    has_employment_history:
+      - employed_at: ROR:1
+        started_at_time: 2023-01-01
+        is_current: true
+    has_familial_relationships:
+      - related_to: P:001
+        type: SIBLING_OF
+    has_medical_history:
+      - started_at_time: 2023-01-01
+        in_location: GEO:1234
+        diagnosis:
+          id: CODE:P1789
+          name: hypertension
+        procedure:
+          id: CODE:P1846
+          name: valve repair
+    addresses:
+      - street: 4 privet drive
+        city: surrey
+companies:
+  - id: ROR:1
+    name: hogwarts
+activities:
+  - id: A:1
+    started_at_time: 2023-01-01
+    was_associated_with: Agent:987
diff --git a/tests/test_validation/test_jsonschemavalidation.py b/tests/test_validation/test_jsonschemavalidation.py
index 54e18f12..6b2f9c80 100644
--- a/tests/test_validation/test_jsonschemavalidation.py
+++ b/tests/test_validation/test_jsonschemavalidation.py
@@ -1,27 +1,40 @@
 import unittest
 
-from linkml_runtime.loaders import json_loader, yaml_loader
-from linkml_runtime.utils.schemaview import SchemaView
+from linkml_runtime.loaders import yaml_loader
 
 from linkml.generators.pythongen import PythonGenerator
 from linkml.validators import JsonSchemaDataValidator
 from tests.test_validation.environment import env
 
 SCHEMA = env.input_path("kitchen_sink.yaml")
-DATA = env.input_path("kitchen_sink_inst_01.yaml")
+INSTANCE_DATA_1 = env.input_path("kitchen_sink_inst_01.yaml")
+INSTANCE_DATA_2 = env.input_path("kitchen_sink_inst_02.yaml")
 
 
 class JsonSchemaValidatorTestCase(unittest.TestCase):
     def test_jsonschema_validation(self):
         """Validate data against a LinkML module using a json-schema validator"""
-        print(f"TEST: Loading {SCHEMA}")
         mod = PythonGenerator(SCHEMA).compile_module()
-        obj = yaml_loader.load(source=DATA, target_class=mod.Dataset)
-        # schema = SchemaView(SCHEMA).schema
+        obj1 = yaml_loader.load(source=INSTANCE_DATA_1, target_class=mod.Dataset)
         v = JsonSchemaDataValidator(schema=SCHEMA)
-        print(f"Validating: {obj}")
-        results = v.validate_object(obj)
-        print(results)
+        # check that jsonschema_objs dict cache is empty before validate_object()
+        # first call
+        self.assertIsNone(v.jsonschema_objs)
+        v.validate_object(obj1, target_class=mod.Dataset)
+
+        obj2 = yaml_loader.load(source=INSTANCE_DATA_2, target_class=mod.Dataset)
+        v.validate_object(obj2, target_class=mod.Dataset)
+
+        # check that the cache store is a dict
+        self.assertEqual(type(v.jsonschema_objs), dict)
+        # check that the cache store is not empty
+        self.assertGreater(len(v.jsonschema_objs.keys()), 0)
+        for f, j in v.jsonschema_objs.items():
+            # check that cache store keys are of type frozendict()
+            self.assertEqual(type(f), frozenset)
+            # check that cache store values are dicts
+            self.assertEqual(type(j), dict)
+            self.assertGreater(len(j.keys()), 0)
 
 
 if __name__ == "__main__":