Source code for linkml.generators.golanggen.golanggen

"""
Golang code generator for LinkML schemas.

Based on the PydanticGenerator architecture.
"""

import logging
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import click
from jinja2 import ChoiceLoader, Environment, FileSystemLoader

from linkml._version import __version__
from linkml.generators.golanggen.build import FieldResult, StructResult
from linkml.generators.golanggen.template import (
    GolangConstant,
    GolangEnum,
    GolangField,
    GolangModule,
    GolangStruct,
    Import,
    Imports,
)
from linkml.generators.oocodegen import PACKAGE, OOCodeGenerator
from linkml.utils.deprecation import deprecated_fields, deprecation_warning
from linkml.utils.generator import read_generator_config, shared_arguments
from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition
from linkml_runtime.utils.formatutils import camelcase, underscore
from linkml_runtime.utils.schemaview import SchemaView

logger = logging.getLogger(__name__)


# Type mapping from LinkML base types to Go types
TYPE_MAP = {
    "str": "string",
    "string": "string",
    "int": "int",
    "integer": "int",
    "Bool": "bool",
    "boolean": "bool",
    "Decimal": "float64",
    "decimal": "float64",
    "float": "float64",
    "double": "float64",
    "XSDDate": "time.Time",
    "XSDDateTime": "time.Time",
    "date": "time.Time",
    "datetime": "time.Time",
    "time": "time.Time",
    "uri": "string",
    "uriorcurie": "string",
    "ncname": "string",
}

# Go primitive types eligible for pointer promotion via ``nullable_primitives``
GO_PRIMITIVE_TYPES = frozenset(
    {
        "string",
        "int",
        "int8",
        "int16",
        "int32",
        "int64",
        "float32",
        "float64",
        "bool",
    }
)

# Fallback package when a schema name yields no usable identifier,
# mirroring JavaGenerator's "example" default.
DEFAULT_GO_PACKAGE = "example"

# The 25 reserved words that may not be used as identifiers.
# https://go.dev/ref/spec#Keywords
GO_KEYWORDS = frozenset(
    {
        "break",
        "case",
        "chan",
        "const",
        "continue",
        "default",
        "defer",
        "else",
        "fallthrough",
        "for",
        "func",
        "go",
        "goto",
        "if",
        "import",
        "interface",
        "map",
        "package",
        "range",
        "return",
        "select",
        "struct",
        "switch",
        "type",
        "var",
    }
)


def _is_valid_go_package(package_name: str) -> bool:
    """Return True if ``package_name`` is a legal Go package identifier.

    Unlike Java, a Go package clause (``package foo``) names a single
    identifier, not a dotted path. It must start with a letter or
    underscore, contain only letters/digits/underscores, and must not be a
    reserved keyword. See specs for grammar/identifier rules:
    https://go.dev/ref/spec#Package_clause
    https://go.dev/ref/spec#Identifiers
    """
    if not package_name or not (package_name[0].isalpha() or package_name[0] == "_"):
        return False
    if any(not (ch.isalnum() or ch == "_") for ch in package_name):
        return False
    if package_name in GO_KEYWORDS:
        return False
    return True


def _derive_go_package(schema_name: str) -> str:
    """Derive a legal Go package identifier from a schema name.

    Mirrors ``JavaGenerator``'s guarantee that the fallback package is always
    valid: a schema name that sanitises to nothing (e.g. ``_private``) falls
    back to :data:`DEFAULT_GO_PACKAGE`, and one that collides with a reserved
    word is suffixed with ``_``, the same escape ``map_name`` applies to
    keyword-named fields.
    """
    # Take the leading segment (e.g. "kitchen_sink" -> "kitchen")
    stem = schema_name.split("_", 1)[0]
    # Keep only characters legal in a Go package name (lowercase, alphanumeric, underscore)
    candidate = re.sub(r"[^a-z0-9_]", "", stem.lower())
    if candidate in GO_KEYWORDS:
        candidate += "_"
    return candidate if _is_valid_go_package(candidate) else DEFAULT_GO_PACKAGE


[docs] @deprecated_fields({"package_name": "package"}, "golanggen-package-name-option") @dataclass class GolangGenerator(OOCodeGenerator): """ Generates Golang code from a LinkML schema. This generator creates idiomatic Go structs with JSON tags from LinkML class definitions. It supports: - Struct generation from classes - Const blocks from enums - Inheritance via struct embedding - Proper type mappings - Multivalued fields as slices - Required vs optional fields """ # ClassVars generatorname = os.path.basename(__file__) generatorversion = "0.2.0" valid_formats = ["go", "golang"] file_extension = "go" # ObjectVars package: PACKAGE | None = None """Override the Go package name. If unset, derived from the schema name. Overrides :class:`~linkml.generators.oocodegen.OOCodeGenerator`'s ``"example"`` default so that an unset package can be told apart from an explicit one and derived from the schema name instead. ``package_name`` is a deprecated alias. """ gen_slots: bool = True """Generate struct fields""" sort_imports: bool = True """Sort imports by group (stdlib, thirdparty, local)""" add_json_tags: bool = True """Add JSON struct tags to fields""" use_time_package: bool = True """Import time package when needed for date/datetime types""" alphabetical_sort: bool = False """Sort structs and enums alphabetically by name for deterministic output.""" nullable_primitives: bool = True """ Use pointer types for optional primitive fields (e.g. ``*string``, ``*int``, ``*bool``). When True (default), optional primitives and ``time.Time`` fields become pointers so that ``omitempty`` only omits ``nil``, preserving intentional zero values. When False, optional primitives and ``time.Time`` use bare Go types. The ``omitzero`` JSON struct tag (Go 1.24+) is added alongside ``omitempty`` so that zero values — including ``time.Time{}`` — are correctly omitted during JSON marshalling. """ named_slot_types: bool = False """ Generate named Go types for parent slots that have ``is_a`` children. When enabled, a slot like ``signature`` with children ``owner_signature`` and ``witness_signature`` produces ``type Signature string`` and all child slots use ``Signature`` instead of the bare ``string``. This adds compile-time type safety between slot families. Named slot types follow the same pointer rules as regular primitives. """ template_dir: str | Path | None = None """ Override templates for each GolangTemplateModel. Directory with templates that override the default templates. If a matching template is not found in the override directory, the default templates will be used. """ # Private attributes _needs_time: bool = field(default=False, init=False) _type_defs: dict[str, tuple[str, str | None]] = field(default_factory=dict, init=False) _parent_slot_names: set[str] = field(default_factory=set, init=False) # Go zero-value defaults by type GO_TYPE_DEFAULTS = { "string": '""', "int": "0", "int8": "0", "int16": "0", "int32": "0", "int64": "0", "float32": "0.0", "float64": "0.0", "bool": "false", "time.Time": "time.Time{}", } def __post_init__(self): super().__post_init__() if self.package in (None, ""): self.package = _derive_go_package(self.schemaview.schema.name) else: # guards against a non-string value, e.g. an unquoted number in config.yaml self.package = str(self.package) if not _is_valid_go_package(self.package): raise ValueError(f"{self.package!r} is not a valid Go package name") @classmethod def validate_generator_args(cls, args: Mapping[str, Any]) -> None: package = args.get("package") if package not in (None, "") and not _is_valid_go_package(str(package)): raise click.UsageError(f"{package!r} is not a valid Go package name") def default_value_for_type(self, typ: str) -> str: """ Return the Go zero value for a given type. Args: typ: The Go type string Returns: The zero value literal for that type """ return self.GO_TYPE_DEFAULTS.get(typ, "nil") def _resolve_underlying_type(self, go_type: str) -> str: """ Resolve a Go type through type definitions to get the underlying primitive type. Named slot types (e.g. ``Signature``) and reference types (e.g. ``PersonId``) are backed by a primitive. This method returns the underlying type so callers can check whether it is a primitive or ``time.Time``. Args: go_type: The Go type string, possibly a named type. Returns: The underlying Go type (e.g. ``"string"``, ``"int"``, ``"time.Time"``). """ td = self._type_defs.get(go_type) return td[0] if td else go_type def _reference_type_for_class(self, class_name: str) -> str: """ Return a Go type name for referencing a class by its identifier. When a slot with a class range is not inlined, the serialised form contains only the identifier value, not the full object. This method creates a named Go type definition (e.g. ``type PersonId string``) and returns its name so it can be used as the field type. Args: class_name: The LinkML class name whose identifier type is needed. Returns: The Go type-definition name (e.g. ``"PersonId"``). """ sv = self.schemaview id_slot = sv.get_identifier_slot(class_name) if id_slot is None: logger.warning(f"No identifier slot for {class_name}, falling back to string") go_type = "string" elif id_slot.range and id_slot.range in sv.all_types(): t = sv.get_type(id_slot.range) if t.base and t.base in TYPE_MAP: go_type = TYPE_MAP[t.base] else: go_type = "string" else: go_type = "string" alias_name = f"{camelcase(class_name)}Id" self._type_defs[alias_name] = (go_type, None) return alias_name def _named_type_for_slot(self, slot_name: str) -> str: """ Return a named Go type for a parent slot that has ``is_a`` children. Creates a type definition (e.g. ``type Signature string``) and returns its name so child slots can use it instead of the bare primitive. The definition is cached in :attr:`_type_defs` so each parent slot produces at most one type definition. Args: slot_name: The LinkML slot name (must be a parent slot). Returns: The Go type-definition name (e.g. ``"Signature"``). """ type_name = camelcase(slot_name) if type_name in self._type_defs: return type_name sv = self.schemaview slot = sv.get_slot(slot_name) slot_range = slot.range if slot.range else "string" if slot_range in sv.all_types(): t = sv.get_type(slot_range) if t.base and t.base in TYPE_MAP: go_type = TYPE_MAP[t.base] else: go_type = "string" else: go_type = "string" description = slot.description if slot.description else None self._type_defs[type_name] = (go_type, description) return type_name @staticmethod def sort_classes(clist: list[ClassDefinition]) -> list[ClassDefinition]: """ Sort classes such that if C is a child of P then C appears after P in the list """ clist = list(clist) slist = [] # sorted while len(clist) > 0: can_add = False for i in range(len(clist)): candidate = clist[i] can_add = False if candidate.is_a: candidates = [candidate.is_a] + candidate.mixins else: candidates = candidate.mixins if not candidates: can_add = True else: if set(candidates) <= set([p.name for p in slist]): can_add = True if can_add: slist = slist + [candidate] del clist[i] break if not can_add: raise ValueError(f"could not find suitable element in {clist} that does not ref {slist}") return slist def generate_struct(self, cls: ClassDefinition) -> StructResult: """ Generate a Go struct from a LinkML class definition. Args: cls: The class definition to convert Returns: StructResult containing the generated struct and any imports needed """ struct_name = camelcase(cls.name) # Handle embedded structs for inheritance embedded_structs = [] if cls.is_a: embedded_structs.append(camelcase(cls.is_a)) if cls.mixins: embedded_structs.extend([camelcase(m) for m in cls.mixins]) go_struct = GolangStruct( name=struct_name, description=cls.description if cls.description else None, embedded_structs=embedded_structs if embedded_structs else None, ) result = StructResult(struct=go_struct, source=cls) # Generate fields — only direct slots, inherited ones come via embedding if self.gen_slots: direct = bool(embedded_structs) slots = [ self.schemaview.induced_slot(sn, cls.name) for sn in self.schemaview.class_slots(cls.name, direct=direct) ] field_results = [] for slot in slots: field_result = self.generate_field(slot, cls) field_results.append(field_result) result = result.merge(field_result) fields = {fr.field.name: fr.field for fr in field_results} result.struct.fields = fields if fields else None return result def generate_field(self, slot: SlotDefinition, cls: ClassDefinition) -> FieldResult: """ Generate a Go struct field from a LinkML slot definition. Args: slot: The slot definition to convert cls: The parent class definition Returns: FieldResult containing the generated field """ slot_alias = slot.alias if slot.alias else slot.name go_name = camelcase(slot_alias) json_name = underscore(slot_alias) go_type = self.generate_go_type(slot, cls) # When pointers are not used (nullable_primitives=False), add the # ``omitzero`` JSON tag for optional primitive and time.Time fields. # ``omitzero`` (Go 1.24+) correctly omits zero values including # ``time.Time{}`` which ``omitempty`` alone does not handle. omitzero = False if not self.nullable_primitives: is_optional = not ((slot.required or False) or (slot.identifier or False) or (slot.key or False)) if is_optional and not slot.multivalued: underlying = self._resolve_underlying_type(go_type) if underlying in GO_PRIMITIVE_TYPES or underlying == "time.Time": omitzero = True field = GolangField( name=slot.name, go_name=go_name, json_name=json_name, type=go_type, required=slot.required or False, identifier=slot.identifier or False, key=slot.key or False, description=slot.description if slot.description else None, pattern=slot.pattern if slot.pattern else None, omitzero=omitzero, ) result = FieldResult(field=field, source=slot) return result def generate_go_type(self, slot: SlotDefinition, cls: ClassDefinition) -> str: """ Generate the Go type for a slot. Args: slot: The slot definition cls: The parent class definition Returns: The Go type string (e.g., "string", "[]Person", "*Address") """ sv = self.schemaview slot_range = slot.range # Determine base type if slot_range in sv.all_classes(): if sv.is_inlined(slot): # Inlined: embed the full struct base_type = camelcase(slot_range) else: # Referenced: use the identifier type base_type = self._reference_type_for_class(slot_range) # Use pointer for optional complex types (single-valued only; # slices are already nil-able and the pointer is added before # the multivalued check below) if not slot.multivalued and not (slot.required or slot.identifier or slot.key): base_type = f"*{base_type}" elif slot_range in sv.all_enums(): # Reference to enum base_type = camelcase(slot_range) elif slot_range in sv.all_types(): # Built-in or custom type t = sv.get_type(slot_range) if t.base and t.base in TYPE_MAP: base_type = TYPE_MAP[t.base] # Track if we need time package if base_type == "time.Time": self._needs_time = True else: logger.warning(f"Unknown type base: {t.name}, defaulting to string") base_type = "string" else: # Default to string base_type = "string" # If the slot's is_a parent is a parent slot, use the named type. # If the slot itself is a parent slot, also use its own named type. if slot.is_a and slot.is_a in self._parent_slot_names: base_type = self._named_type_for_slot(slot.is_a) elif slot.name in self._parent_slot_names: base_type = self._named_type_for_slot(slot.name) # Resolve the underlying Go type for pointer-promotion checks. # Named slot types backed by primitives follow the same rules. underlying_type = self._resolve_underlying_type(base_type) # Pointer promotion for optional fields. # # When ``nullable_primitives`` is True (default), optional primitives # and ``time.Time`` become pointers so ``omitempty`` only omits # ``nil``, preserving intentional zero values. # # When ``nullable_primitives`` is False, bare types are used instead # and the ``omitzero`` JSON tag (Go 1.24+) is added in # :meth:`generate_field` to correctly omit zero values including # ``time.Time{}``. is_optional = not (slot.required or slot.identifier or slot.key) if self.nullable_primitives and not slot.multivalued and is_optional: if underlying_type == "time.Time" or underlying_type in GO_PRIMITIVE_TYPES: base_type = f"*{base_type}" # Handle multivalued slots if slot.multivalued: return f"[]{base_type}" return base_type def generate_enum(self, enum: EnumDefinition) -> GolangEnum: """ Generate a Go const block from a LinkML enum definition. Args: enum: The enum definition to convert Returns: GolangEnum model """ enum_name = camelcase(enum.name) constants = {} if enum.permissible_values: for pv_name, pv in enum.permissible_values.items(): const_name = camelcase(pv_name) # Use the text value if available, otherwise the name const_value = pv.text if pv.text else pv_name constants[pv_name] = GolangConstant( name=f"{enum_name}{const_name}", value=const_value, description=pv.description if pv.description else None, ) return GolangEnum( name=enum_name, type="string", # Default to string type for enums description=enum.description if enum.description else None, values=constants, ) def render(self) -> GolangModule: """ Render the schema to a GolangModule model. Returns: GolangModule containing all generated structs and enums """ sv: SchemaView = self.schemaview self._type_defs = {} self._needs_time = False # Precompute which slots have is_a children (parent slots get named types) self._parent_slot_names = set() if self.named_slot_types: for slot_name in sv.all_slots(): if sv.slot_children(slot_name): self._parent_slot_names.add(slot_name) # Generate enums enums = {} for enum_def in sv.all_enums().values(): enum_model = self.generate_enum(enum_def) enums[enum_model.name] = enum_model # Generate structs structs = {} all_classes = list(sv.all_classes().values()) sorted_classes = self.sort_classes(all_classes) for cls in sorted_classes: struct_result = self.generate_struct(cls) structs[struct_result.struct.name] = struct_result.struct # Add type definitions for referenced (non-inlined) class identifiers # and named slot types. for td_name, (go_type, description) in self._type_defs.items(): structs[td_name] = GolangStruct( name=td_name, description=description, is_type_alias=True, type_alias_value=go_type, ) # Sort alphabetically for deterministic output if self.alphabetical_sort: enums = dict(sorted(enums.items())) structs = dict(sorted(structs.items())) # Build imports imports = Imports() if self._needs_time and self.use_time_package: imports += Import(module="time") if self.sort_imports: imports.sort() module = GolangModule( package_name=self.package, imports=imports, enums=enums, structs=structs, ) return module
[docs] def serialize(self, rendered_module: GolangModule | None = None) -> str: """ Serialize the schema to a Go source code string. Args: rendered_module: Optional pre-rendered module Returns: Go source code as a string """ if rendered_module is not None: module = rendered_module else: module = self.render() return module.render(environment=self._template_environment())
def _template_environment(self) -> Environment: """Get the Jinja2 template environment. If :attr:`template_dir` is set, templates in that directory take priority over the built-in defaults via a :class:`jinja2.ChoiceLoader`. """ env = GolangModule.environment() if self.template_dir is not None: loader = ChoiceLoader([FileSystemLoader(self.template_dir), env.loader]) env.loader = loader return env
_TEMPLATE_NAMES = [ "module.go.jinja", "struct.go.jinja", "field.go.jinja", "enum.go.jinja", "imports.go.jinja", ] @shared_arguments(GolangGenerator) @click.option( "--package", "package", type=str, help="Override the Go package name (default: derived from schema name)", ) @click.option( "--package-name", "package_name", type=str, help="DEPRECATED alias for --package", ) @click.option( "--config-file", "-C", type=click.File("rb"), help="Path to a gen-project-style YAML config file setting " "'generator_args: {golang: {package: ...}}'. An explicit --package always " "takes precedence over the config file.", ) @click.option( "--alphabetical-sort/--no-alphabetical-sort", default=False, show_default=True, help="Sort structs and enums alphabetically for deterministic output.", ) @click.option( "--nullable-primitives/--no-nullable-primitives", default=True, show_default=True, help="Use pointer types for optional primitives so omitempty only triggers on nil.", ) @click.option( "--named-slot-types/--no-named-slot-types", default=False, show_default=True, help="Generate named Go types for parent slots with is_a children for type safety.", ) @click.option( "--template-dir", type=click.Path(), help=""" Optional jinja2 template directory to use for Go code generation. Pass a directory containing templates with the same name as any of the default GolangTemplateModel templates to override them. The given directory will be searched for matching templates, and use the default templates as a fallback if an override is not found. Available templates to override: \b """ + "\n".join(["- " + name for name in _TEMPLATE_NAMES]), ) @click.version_option(__version__, "-V", "--version") @click.command(name="golang") def cli( yamlfile, package: str | None = None, package_name: str | None = None, config_file=None, alphabetical_sort: bool = False, nullable_primitives: bool = True, named_slot_types: bool = False, template_dir: str | None = None, **args, ): """ Generate Golang structs from a LinkML schema. This generator produces idiomatic Go code with: - Structs for classes - Const blocks for enums - JSON tags for serialization - Struct embedding for inheritance """ if package_name is not None: deprecation_warning("golanggen-package-name-option") if package is None: package = package_name if package is None: package = read_generator_config(config_file, "golang").get("package") GolangGenerator.validate_generator_args({"package": package}) if template_dir is not None: if not Path(template_dir).exists(): raise FileNotFoundError(f"The template directory {template_dir} does not exist!") # The package was already validated above; a ValueError raised while actually # constructing the generator here is therefore a genuine failure (e.g. an # unloadable schema), not a bad package name, and is left to propagate with # its own traceback rather than being caught and mistaken for one. gen = GolangGenerator( yamlfile, package=package, alphabetical_sort=alphabetical_sort, nullable_primitives=nullable_primitives, named_slot_types=named_slot_types, template_dir=template_dir, **args, ) print(gen.serialize()) if __name__ == "__main__": cli()