Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions besser/generators/spring/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Spring Boot backend generator.

Only :class:`SpringBackendGenerator` is public: the entity, repository, service,
controller and HTTP writers are internal helpers it composes, and they are not
usable on their own.
"""

from .spring_backend_generator import (
DEFAULT_JAVA_VERSION,
DEFAULT_SPRING_APP_NAME,
DEFAULT_SPRING_BOOT_VERSION,
SpringBackendGenerator,
)

__all__ = [
"SpringBackendGenerator",
"DEFAULT_SPRING_BOOT_VERSION",
"DEFAULT_JAVA_VERSION",
"DEFAULT_SPRING_APP_NAME",
]
113 changes: 113 additions & 0 deletions besser/generators/spring/_sub_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Internal base class shared by the Spring sub-generators.

The entity, repository, service, controller and HTTP writers are *not* stand-alone
generators: they are never registered in ``SUPPORTED_GENERATORS`` and they only
make sense inside the directory layout that
:class:`~besser.generators.spring.spring_backend_generator.SpringBackendGenerator`
lays out. They therefore deliberately do not implement ``GeneratorInterface`` —
they are plain helpers composed by the public generator, in the same way
``WebAppGenerator`` composes its own writers.
"""

import os
from pathlib import Path

from jinja2 import Environment, FileSystemLoader

from besser.BUML.metamodel.structural import (
Class,
DateTimeType,
DateType,
DomainModel,
Enumeration,
TimeType,
)
from besser.generators.spring.java_types import (
is_many,
java_type_for,
java_type_import,
to_java_accessor_suffix,
to_java_class_name,
to_java_field_name,
)

TEMPLATES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")

#: B-UML types that get an extra ``findAllBy<Attribute>Between`` range finder.
RANGE_FINDER_TYPES = (DateType.name, DateTimeType.name, TimeType.name)


def build_environment(**options) -> Environment:
"""Environment: A Jinja2 environment bound to the Spring template folder."""
return Environment(loader=FileSystemLoader(TEMPLATES_PATH), **options)


class SpringSubGenerator:
"""Common plumbing for the Spring sub-generators (internal helper)."""

def __init__(self, model: DomainModel, output_dir: str | Path):
self.model: DomainModel = model
self.output_dir: Path = Path(output_dir)
self.enumerations: set[Enumeration] = model.get_enumerations()
self.classes: list[Class] = model.classes_sorted_by_inheritance()
# Names of every user-defined type, used to tell a class/enum reference
# apart from a primitive type when mapping to Java.
self.model_type_names: set[str] = (
{cls.name for cls in self.classes} | {enum.name for enum in self.enumerations}
)

def concrete_classes(self) -> list[Class]:
"""list[Class]: The non-abstract classes, in a deterministic order."""
return [cls for cls in self.classes if not cls.is_abstract]

def derived_finder_methods(self, cls: Class, entity_package_name: str,
imports: set[str]) -> list[dict]:
"""Build the ``findAllBy<Attribute>`` query methods derived from a class.

The repository interface, the service interface and the service
implementation all render the very same list, so that the delegation in
the implementation always matches the repository signature. ``imports``
is extended in place with whatever the signatures need.
"""
class_name = to_java_class_name(cls.name)
methods: list[dict] = []

for attr in sorted(cls.attributes, key=lambda a: a.name):
if is_many(attr.multiplicity) or attr.is_id:
continue

parameter_type = java_type_for(attr.type, self.model_type_names)
if attr.type.name in self.model_type_names:
imports.add(f"{entity_package_name}.{parameter_type}")
type_import = java_type_import(parameter_type)
if type_import:
imports.add(type_import)

finder_suffix = to_java_accessor_suffix(attr.name)

if attr.type.name in RANGE_FINDER_TYPES:
methods.append({
"return_value": f"ArrayList<{class_name}>",
"name": f"findAllBy{finder_suffix}Between",
"parameter": f"{parameter_type} start, {parameter_type} end",
})

methods.append({
"return_value": f"ArrayList<{class_name}>",
"name": f"findAllBy{finder_suffix}",
"parameter": f"{parameter_type} {to_java_field_name(attr.name)}",
})

if methods:
imports.add("java.util.ArrayList")

return sorted(methods, key=lambda method: method["name"])

def write(self, relative_path: str | Path, content: str) -> str:
"""str: Write ``content`` under the output directory, creating parents."""
file_path = self.output_dir / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
# newline="\n" keeps the generated Java byte-identical across platforms.
with open(file_path, mode="w", encoding="utf-8", newline="\n") as file:
file.write(content)
return str(file_path)
237 changes: 237 additions & 0 deletions besser/generators/spring/java_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
"""Shared Java type mapping and naming helpers for the Spring Boot generator.

Every sub-generator of the Spring package (entity, repository, service,
controller) resolves B-UML types and B-UML names through this module, so that a
single attribute is never rendered with one Java type in the entity and another
one in the repository signature that reads it.

The naming helpers also act as the security boundary of the generator:
``NamedElement`` only rejects whitespace and hyphens, so a model may legally
contain a class called ``../../evil``. Every B-UML name that ends up as a file
name, a Java type, a field or a SQL identifier is therefore funnelled through
:func:`to_java_identifier` (or one of its wrappers) first.
"""

import re

from besser.BUML.metamodel.structural import (
UNLIMITED_MAX_MULTIPLICITY,
AnyType,
BooleanType,
Class,
DateTimeType,
DateType,
Enumeration,
FloatType,
IntegerType,
Multiplicity,
StringType,
TimeDeltaType,
TimeType,
Type,
)

#: Single source of truth for the B-UML primitive type -> Java type mapping.
#: Shared by the entity, repository, service and controller generators so that
#: a ``time`` attribute is a ``LocalTime`` everywhere it appears.
JAVA_TYPES: dict[str, str] = {
StringType.name: "String",
BooleanType.name: "Boolean",
IntegerType.name: "Integer",
FloatType.name: "Float",
DateType.name: "LocalDate",
DateTimeType.name: "LocalDateTime",
TimeType.name: "LocalTime",
TimeDeltaType.name: "Duration",
AnyType.name: "Object",
}

#: Java type used for any B-UML type that has no explicit mapping.
DEFAULT_JAVA_TYPE: str = "Object"

#: ``java.*`` imports required by the Java types that are not in ``java.lang``.
JAVA_TYPE_IMPORTS: dict[str, str] = {
"LocalDate": "java.time.LocalDate",
"LocalDateTime": "java.time.LocalDateTime",
"LocalTime": "java.time.LocalTime",
"Duration": "java.time.Duration",
}

#: Reserved words and literals that cannot be used as Java identifiers.
JAVA_KEYWORDS: frozenset[str] = frozenset({
"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",
"true", "false", "null", "_",
})

#: A Java package is a dot-separated list of lowercase identifiers.
JAVA_PACKAGE_PATTERN = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)*$")

_ILLEGAL_IDENTIFIER_CHARS = re.compile(r"[^0-9A-Za-z_$]+")
_SNAKE_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)")
_SNAKE_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])")

#: Fallback used when a name sanitizes down to nothing at all (e.g. ``"../.."``).
_FALLBACK_IDENTIFIER = "Unnamed"


def to_java_identifier(name: str, capitalize: bool = False) -> str:
"""Turn an arbitrary B-UML name into a legal Java identifier.

Every character that Java does not accept in an identifier is replaced by an
underscore, which is what stops path separators and ``..`` segments from
escaping the output directory. Identifiers that would start with a digit are
prefixed with an underscore, and Java keywords get a trailing underscore.

Args:
name: The B-UML name to sanitize.
capitalize: Whether the first character should be upper-cased
(used for type names).

Returns:
A legal, non-empty Java identifier.
"""
cleaned = _ILLEGAL_IDENTIFIER_CHARS.sub("_", str(name))
# Underscores introduced by the substitution at the edges carry no meaning
# (``../../evil`` -> ``_evil``) and only make the generated code noisier.
cleaned = cleaned.strip("_")
if not cleaned:
cleaned = _FALLBACK_IDENTIFIER
if cleaned[0].isdigit():
cleaned = f"_{cleaned}"
if capitalize:
# Capitalizing first is what makes a class called "new" legal as "New";
# only what is left after it can still collide with a keyword.
cleaned = cleaned[0].upper() + cleaned[1:]
if cleaned in JAVA_KEYWORDS:
cleaned = f"{cleaned}_"
return cleaned


def to_java_class_name(name: str) -> str:
"""str: The sanitized, capitalized Java type name for a B-UML name."""
return to_java_identifier(name, capitalize=True)


def to_java_field_name(name: str) -> str:
"""str: The sanitized Java field/parameter name for a B-UML name."""
return to_java_identifier(name)


def to_java_accessor_suffix(name: str) -> str:
"""str: The capitalized suffix of the ``getX``/``setX`` pair for a field."""
field = to_java_field_name(name)
return field[0].upper() + field[1:]


def to_snake_case(name: str) -> str:
"""str: The sanitized ``snake_case`` form of a name (column/table names)."""
sanitized = to_java_identifier(name)
partial = _SNAKE_BOUNDARY_1.sub(r"\1_\2", sanitized)
return _SNAKE_BOUNDARY_2.sub(r"\1_\2", partial).lower()


def pluralize(name: str) -> str:
"""str: A naive English plural, used for ``@Table`` names."""
if name.endswith("y"):
return name[:-1] + "ies"
if name.endswith(("s", "x", "z", "ch", "sh")):
return name + "es"
return name + "s"


#: B-UML visibility -> Java access modifier. ``package`` is the default access
#: in Java and has no keyword, so it maps to the empty modifier.
JAVA_VISIBILITY: dict[str, str] = {
"public": "public",
"private": "private",
"protected": "protected",
"package": "",
}


def java_visibility(visibility: str) -> str:
"""str: The Java access modifier for a B-UML visibility."""
return JAVA_VISIBILITY.get(visibility, "public")


def validate_java_package(package_name: str) -> str:
"""Validate a Java package name and return it unchanged.

The package name is split into directories, so an unvalidated value would be
a second way out of the output directory.

Raises:
ValueError: If the package name is not a dot-separated list of lowercase
Java identifiers, or if one of its segments is a Java keyword.
"""
if not isinstance(package_name, str) or not JAVA_PACKAGE_PATTERN.match(package_name):
raise ValueError(
f"Invalid Java package name: {package_name!r}. A package name must be a "
"dot-separated list of lowercase identifiers, e.g. 'com.example.app'."
)
for segment in package_name.split("."):
if segment in JAVA_KEYWORDS:
raise ValueError(
f"Invalid Java package name: {package_name!r}. "
f"'{segment}' is a reserved Java keyword."
)
return package_name


def java_type_for(buml_type: Type, model_type_names: set[str] | None = None) -> str:
"""Map a B-UML type to its Java counterpart.

User-defined types (classes and enumerations) keep their own, sanitized
name. Primitive types are looked up in :data:`JAVA_TYPES`, and anything the
mapping does not know about degrades to ``Object`` rather than raising.

Args:
buml_type: The B-UML type to map.
model_type_names: Names of the classes and enumerations of the model.
Used so that a type referenced by name only is still recognised as
user-defined.
"""
if buml_type is None:
return "void"
name = buml_type.name
if isinstance(buml_type, (Class, Enumeration)) or (model_type_names and name in model_type_names):
return to_java_class_name(name)
return JAVA_TYPES.get(name, DEFAULT_JAVA_TYPE)


def java_type_import(java_type_name: str) -> str | None:
"""str | None: The import required by a Java type name, if any."""
return JAVA_TYPE_IMPORTS.get(java_type_name)


def is_many(multiplicity: Multiplicity | None) -> bool:
"""bool: Whether a multiplicity denotes "many" (an upper bound above one).

``*`` is stored as :data:`UNLIMITED_MAX_MULTIPLICITY` by the metamodel, so
both the unbounded and the explicitly bounded cases are covered here.
"""
if multiplicity is None:
return False
return multiplicity.max == UNLIMITED_MAX_MULTIPLICITY or multiplicity.max > 1


def get_id_attribute(cls: Class):
"""Property: The identifier attribute of a class, inherited ones included.

Raises:
ValueError: If the class has no attribute flagged with ``is_id``.
"""
id_attributes = [attr for attr in cls.all_attributes() if attr.is_id]
if not id_attributes:
raise ValueError(
f"Class '{cls.name}' has no identifier attribute. The Spring generator maps "
"every concrete class to a JPA entity, which requires exactly one attribute "
"marked with 'is_id=True' (directly or inherited)."
)
return sorted(id_attributes, key=lambda attr: attr.name)[0]
3 changes: 3 additions & 0 deletions besser/generators/spring/resources/maven-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
Loading
Loading