Source code for linkml.generators.javagen

import logging
import os
from dataclasses import dataclass, field
from pathlib import Path

import click
from jinja2 import Template

from linkml._version import __version__
from linkml.generators.oocodegen import OOCodeGenerator, OODocument
from linkml.utils.deprecation import deprecated_fields, deprecation_warning
from linkml.utils.generator import shared_arguments
from linkml_runtime.linkml_model.meta import ClassDefinition, SlotDefinition, TypeDefinition
from linkml_runtime.utils.formatutils import camelcase

DEFAULT_TEMPLATE_DIR = Path(__file__).parent.resolve() / "javagen"

TYPEMAP = {
    "xsd:string": "String",
    "xsd:integer": "Integer",
    "xsd:float": "Float",
    "xsd:double": "Double",
    "xsd:boolean": "Boolean",
    "xsd:dateTime": "ZonedDateTime",
    "xsd:date": "LocalDate",
    "xsd:time": "LocalTime",
    "xsd:anyURI": "URI",
    "xsd:decimal": "BigDecimal",
}

JAVA_KEYWORDS = [
    "abstract",
    "assert",
    "boolean",
    "break",
    "byte",
    "case",
    "catch",
    "char",
    "class",
    "const",
    "continue",
    "default",
    "do",
    "double",
    "else",
    "enum",
    "extends",
    "final",
    "finally",
    "float",
    "for",
    "goto",
    "if",
    "implements",
    "import",
    "instanceof",
    "int",
    "interface",
    "long",
    "native",
    "new",
    "package",
    "private",
    "protected",
    "public",
    "return",
    "short",
    "static",
    "strictfp",
    "super",
    "switch",
    "synchronized",
    "this",
    "throw",
    "throws",
    "transient",
    "try",
    "void",
    "volatile",
    "while",
]

TYPE_DEFAULTS = {"boolean": "false", "int": "0", "float": "0f", "double": "0d", "String": '""'}


@dataclass
class OOCustomDocument(OODocument):
    """A document that represents something else than a class or an enum."""

    type: str = None


@dataclass
class OOVisitorDocument(OOCustomDocument):
    """A document representing a visitor interface."""

    visited_object: str = None

    def __post_init__(self):
        self.type = "_visitor"


@dataclass
class JavaBundle:
    """In-memory result of rendering a LinkML schema to Java source.

    A ``JavaBundle`` is the output of :meth:`JavaGenerator.render`. It carries
    the rendered Java source for every file that would be written to disk by
    :meth:`JavaGenerator.serialize`, keyed by filename.

    Mirrors render/serialize split used by `rustgen.RustGenerator` (FileResult /
    CrateResult) and `pydanticgen.PydanticGenerator` (PydanticModule).
    """

    files: dict[str, str] = field(default_factory=dict)
    """Rendered Java source, keyed by filename (e.g. ``"Address.java"``)."""

    package: str = ""
    """Java package name the rendered files belong to (informational)."""


class TemplateCache:
    """Cache for template objects.

    The purpose of this class is twofold:

    * It implements the logic needed to find the correct template based on (1)
      which templates are available, (2) which type of object is a template
      required for, (3) whether a specific “variant“ of templates has been
      requested.
    * It keeps templates that have already been read from disk in memory, so
      that we don’t have to read them over again when the same template is used
      many times (which should be the typical case).
    """

    def __init__(self):
        self.template_files: dict[str, Path] = {}
        self.templates: dict[Path, Template] = {}

    def add_directory(self, template_dir: Path) -> None:
        """Adds all templates in the specified directory to the cache."""

        for template in template_dir.glob("*.jinja2"):
            self.template_files[template.stem] = template

    def force_template(self, template_file: Path) -> None:
        """Sets the template to systematically use for all objects.

        This method is used to implement the `--template-file` option, allowing
        users to forcibly use one specific template file, regardless of the
        contents of the templates directory.
        """

        self.template_files["__FORCE__"] = template_file

    def get_template(self, name: str, fallback: str = "class", variant: str | None = None) -> Template | None:
        """Finds the template for a given object.

        :param name: The name of the object for which a template is required.
        :param fallback: The name of the fallback template to use if there is
            no specific template for the given object name.
        :param variant: The name of an optional template variant.
        :return: The requested template, or None if no suitable template is
            available.
        """

        candidate: Path | None = None

        candidate = self.template_files.get("__FORCE__")

        if candidate is None and variant is not None:
            candidate = self.template_files.get(name + "-" + variant)
            if candidate is None:
                candidate = self.template_files.get(fallback + "-" + variant)

        if candidate is None:
            candidate = self.template_files.get(name)
        if candidate is None:
            candidate = self.template_files.get(fallback)

        if candidate is None:
            return None

        if candidate not in self.templates:
            with candidate.open("r") as f:
                self.templates[candidate] = Template(f.read())
        return self.templates[candidate]


[docs] @deprecated_fields({"head": "metadata", "emit_metadata": "metadata"}) @dataclass class JavaGenerator(OOCodeGenerator): """ Generates java code from a LinkML schema. This generators supports an arbitrary number of different styles through the use of “template variants“. Currently, two variants are available: - the default variant represents LinkML classes as Java classes carrying Lombok annotations (https://projectlombok.org); - the `records` variant represents LinkML classes as Java 16 records. """ # ClassVars generatorname = os.path.basename(__file__) generatorversion = "0.0.1" valid_formats = ["java"] file_extension = "java" # ObjectVars template_file: str | None = None template_dir: Path | None = None template_cache: TemplateCache = field(default_factory=lambda: TemplateCache()) gen_classvars: bool = True gen_slots: bool = True genmeta: bool = False def __post_init__(self) -> None: self.template_cache.add_directory(DEFAULT_TEMPLATE_DIR) if self.template_dir is not None: self.template_cache.add_directory(self.template_dir) if self.template_file is not None: self.template_cache.force_template(Path(self.template_file)) super().__post_init__() def default_value_for_type(self, typ: str) -> str: return TYPE_DEFAULTS.get(typ, "null") def map_class(self, c: ClassDefinition) -> str: # A slot intended to accept anything is represented in Java as # an Object-typed slot if c.class_uri == "linkml:Any": return "Object" else: return super().map_class(c) def map_name(self, name: str) -> str: if name in JAVA_KEYWORDS: return name + "_" return name def map_type(self, t: TypeDefinition, required: bool = False) -> str: if t.uri: # We use "boxed" types (Boolean, Integer, Double, Float) by # default because we need to represent the case where a # value has not explicitly been set. But that requirement no # longer holds when required == true, so in that case we can # use primitive types (boolean, int, double, float) instead. typ = TYPEMAP.get(t.uri) if required and (typ == "Boolean" or typ == "Double" or typ == "Float"): typ = typ.lower() elif required and typ == "Integer": typ = "int" return typ elif t.typeof: return self.map_type(self.schemaview.get_type(t.typeof)) else: raise ValueError(f"{t} cannot be mapped to a type") def render( self, template_variant: str | None = None, extra_templates: list[str] | None = None, visitors: list[str] | None = None, ) -> JavaBundle: """Render the schema to an in-memory :class:`JavaBundle`. Pure counterpart of :meth:`serialize`: returns the rendered Java source for every file without touching the filesystem. :param template_variant: The name of the template variant to use, if any. :param extra_templates: A list of additional templates from which to generate additional code files. For example, if set to `[Foo,Bar]`, this will generate two additional files `Foo.java` and `Bar.java` (assuming the template directory contains the required templates `Foo.jinja2` and `Bar.jinja2`). Users can exploit such additional files to generate any code they might need in addition to the code generated for each class and each enum in the model. :param visitors: A list of class names for which to generate a visitor interface. For example, if set to `[Foo]`, this will generate a `IFooVisitor` interface, and the generated code for both the `Foo` class and all its descendants will include a `accept(IFooVisitor)` method. :return: A :class:`JavaBundle` whose ``files`` maps each output filename (e.g. ``"Address.java"``) to its rendered source. """ oodocs = self.create_documents() # Create additional documents for additional templates and visitors if extra_templates: for extra_template in extra_templates: oodocs.append(OOCustomDocument(name=extra_template, package=self.package, type=extra_template)) if visitors is not None: for visitor in visitors: visited_name = visitor visitor_name = "I" + camelcase(visited_name) + "Visitor" oodocs.append(OOVisitorDocument(name=visitor_name, package=self.package, visited_object=visited_name)) else: visitors = [] files: dict[str, str] = {} for oodoc in oodocs: cls = None enum = None if oodoc.classes: cls = oodoc.classes[0] type = "class" elif oodoc.enums: enum = oodoc.enums[0] type = "enum" else: # Should be a OOCustomDocument type = oodoc.type template = self.template_cache.get_template(oodoc.name, type, template_variant) if template is None: raise Exception(f"Missing template for {oodoc.name}") code = template.render( doc=oodoc, cls=cls, enum=enum, gen=self, visitors=visitors, metamodel_version=self.schema.metamodel_version, model_version=self.schema.version, ) files[f"{oodoc.name}.java"] = code return JavaBundle(files=files, package=self.package)
[docs] def serialize( self, directory: str | Path, template_variant: str | None = None, extra_templates: list[str] | None = None, visitors: list[str] | None = None, rendered_module: JavaBundle | None = None, **kwargs, ) -> None: """Generate the Java code and write it to ``directory``, one file per class. Java requires one public class per file, so there is no meaningful single-string serialization of a schema; callers that want the generated code in memory should use :meth:`render` and work from the returned :class:`JavaBundle` instead. :param directory: The directory where to write the code files. :param template_variant: The name of the template variant to use, if any. Ignored when ``rendered_module`` is provided. :param extra_templates: A list of additional templates from which to generate additional code files. See :meth:`render` for details. Ignored when ``rendered_module`` is provided. :param visitors: A list of class names for which to generate a visitor interface. See :meth:`render` for details. Ignored when ``rendered_module`` is provided. :param rendered_module: Optional pre-computed :class:`JavaBundle` to write instead of calling :meth:`render` afresh. Allows caller to render once and inspect/write multiple times. When supplied, ``template_variant``, ``extra_templates``, and ``visitors`` are ignored (the bundle is used as-is). """ bundle = ( rendered_module if rendered_module is not None else self.render( template_variant=template_variant, extra_templates=extra_templates, visitors=visitors, ) ) os.makedirs(directory, exist_ok=True) for filename, code in bundle.files.items(): path = os.path.join(directory, filename) with open(path, "w", encoding="UTF-8") as stream: stream.write(code)
# The following methods are intended to be used from within a code # template. def has_ancestor(self, cls: ClassDefinition, name: str) -> bool: """Checks for an ancestor in a class inheritance tree. :param cls: A ClassDefinition object. :param name: A class name. :returns: True if cls has any ancestor with the specified name. """ if cls.is_a is None: return False elif cls.is_a == name: return True else: return self.has_ancestor(self.schemaview.get_class(cls.is_a), name) def get_descendants(self, name: str, _descendants=None) -> list[str]: """Gets all the descendants of a class. :param name: A class name. :param _descendants: The list to which to append the names of the descendant classes. :returns: A flat list of the names of all classes that inherit from the named class. """ if _descendants is None: _descendants = [] for child in self.schemaview.class_children(name): _descendants.append(child) self.get_descendants(child, _descendants) return _descendants def get_class_name(self, name: str) -> str: """Converts a LinkML class name to a Java class name.""" return camelcase(name) def get_write_accessor_name(self, slot: SlotDefinition) -> str: """Gets the name of the write accessor for the given slot. This is to allow templates to generate their own write accessors, should they prefer not to use Lombok. """ name = self.get_slot_actual_name(slot) if slot.range == "boolean" and slot.required: # This replicates the logic used by Lombok for boolean-typed fields: # - foo -> setFoo() (general case) # - isFoo -> setFoo() (special case to avoid setIsFoo()) if len(name) > 2 and name[:2] == "is" and not name[2].islower(): return "set" + camelcase(name[2:]) return "set" + camelcase(name) def get_read_accessor_name(self, slot: SlotDefinition) -> str: """Gets the name of the read accessor for the given slot.""" name = self.get_slot_actual_name(slot) if slot.range == "boolean" and slot.required: # This replicates the logic used by Lombok for boolean-typed fields # - foo -> isFoo() (general case) # - isFoo -> isFoo() (special case to avoid isIsFoo()) if len(name) > 2 and name[:2] == "is" and not name[2].islower(): return self.get_slot_name(name) else: return "is" + camelcase(name) else: return "get" + camelcase(name) def get_slot_actual_name(self, slot: SlotDefinition) -> str: return slot.alias if slot.alias and self.use_aliases else slot.name
@shared_arguments(JavaGenerator) @click.option( "--output-directory", default="output", show_default=True, help="Output directory for individually generated class files", ) @click.option("--package", help="Package name where relevant for generated class files") @click.option( "--template-dir", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), help="Directory containing the Jinja2 templates to use", ) @click.option("--template-variant", help="Use the specified template variant") @click.option( "--template-file", help="""Optional jinja2 template to use for class generation (takes precedence over --template-dir)""", ) @click.option( "--generate-records/--no-generate-records", default=False, help="""Optional Java 17 record implementation (deprecated, use --template-variant=records instead)""", ) @click.option("--extra-template", multiple=True, help="Name of an additional, arbitrary template to use") @click.option("--visitor", multiple=True, help="Generate a visitor interface for the specified class") @click.option("--true-enums/--no-true-enums", default=False, help="Treat enums as distinct types rather than strings") @click.option("--use-aliases/--no-use-aliases", default=False, help="Use aliases when available to name fields") @click.version_option(__version__, "-V", "--version") @click.command(name="java") def cli( yamlfile, output_directory=None, package=None, template_dir=None, template_variant=None, template_file=None, generate_records=False, head=None, emit_metadata=None, genmeta=False, classvars=True, slots=True, true_enums=False, use_aliases=False, extra_template=[], visitor=[], **args, ): """Generate java classes to represent a LinkML model""" if generate_records: template_variant = "records" if template_file is not None: if template_dir is not None or template_variant is not None: logging.warning("--template-file will take precedence over --template-dir and --template-variant") # default is adding metadata to the generated code if "metadata" not in args: args["metadata"] = True # deprecated arguments are replaced, head overwrites emit_metadata if emit_metadata is not None: deprecation_warning("metadata-flag") args["metadata"] = emit_metadata if head is not None: deprecation_warning("metadata-flag") args["metadata"] = head JavaGenerator( yamlfile, package=package, template_dir=template_dir, template_file=template_file, genmeta=genmeta, gen_classvars=classvars, gen_slots=slots, true_enums=true_enums, use_aliases=use_aliases, **args, ).serialize( output_directory, template_variant=template_variant, extra_templates=extra_template, visitors=visitor, **args ) if __name__ == "__main__": cli()