From 890577313280099c13df52c0ae56b002aae48614 Mon Sep 17 00:00:00 2001 From: Ingo Schnabel Date: Fri, 15 May 2026 12:51:34 +0200 Subject: [PATCH] Model generator --- .idea/compiler.xml | 46 -- peakup-tools/pom.xml | 5 + .../ElementCollectionDefinition.java | 17 + .../tools/generator/EmbeddableDefinition.java | 10 + .../tools/generator/EnumDefinition.java | 10 + .../tools/generator/EnumValueDefinition.java | 7 + .../tools/generator/FieldDefinition.java | 13 + .../tools/generator/ModelDefinition.java | 20 + .../tools/generator/ModelGeneratorTool.java | 659 ++++++++++++++++++ .../tools/generator/OneToManyDefinition.java | 17 + .../resources/example-model-definition.json | 60 ++ 11 files changed, 818 insertions(+), 46 deletions(-) create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/ElementCollectionDefinition.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/EmbeddableDefinition.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/EnumDefinition.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/EnumValueDefinition.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/FieldDefinition.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/ModelDefinition.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java create mode 100644 peakup-tools/src/main/java/com/peakup/tools/generator/OneToManyDefinition.java create mode 100644 peakup-tools/src/main/resources/example-model-definition.json diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 47fcaa5..141c1e5 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -61,52 +61,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/peakup-tools/pom.xml b/peakup-tools/pom.xml index 15e7397..c9fa8c1 100644 --- a/peakup-tools/pom.xml +++ b/peakup-tools/pom.xml @@ -35,6 +35,11 @@ jackson-datatype-jsr310 2.20.1 + + com.squareup + javapoet + 1.13.0 + diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/ElementCollectionDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/ElementCollectionDefinition.java new file mode 100644 index 0000000..b88a49d --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/ElementCollectionDefinition.java @@ -0,0 +1,17 @@ +package com.peakup.tools.generator; + +import jakarta.annotation.Nullable; + +public class ElementCollectionDefinition { + + public String fieldName; + public String collectionTable; + public String joinColumn; + /** Simple type name: "String" for scalar collections, or an embeddable class name. */ + public String valueType; + /** Column name for scalar (String) collections; null for embeddable collections. */ + public @Nullable String valueColumn; + public @Nullable String orderColumn; + /** Inline embeddable definition — generated alongside the entity when present. */ + public @Nullable EmbeddableDefinition embeddable; +} \ No newline at end of file diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/EmbeddableDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/EmbeddableDefinition.java new file mode 100644 index 0000000..15a3fb9 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/EmbeddableDefinition.java @@ -0,0 +1,10 @@ +package com.peakup.tools.generator; + +import java.util.ArrayList; +import java.util.List; + +public class EmbeddableDefinition { + + public String className; + public List fields = new ArrayList<>(); +} diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/EnumDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/EnumDefinition.java new file mode 100644 index 0000000..57fea13 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/EnumDefinition.java @@ -0,0 +1,10 @@ +package com.peakup.tools.generator; + +import java.util.ArrayList; +import java.util.List; + +public class EnumDefinition { + + public String className; + public List values = new ArrayList<>(); +} \ No newline at end of file diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/EnumValueDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/EnumValueDefinition.java new file mode 100644 index 0000000..3a0a520 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/EnumValueDefinition.java @@ -0,0 +1,7 @@ +package com.peakup.tools.generator; + +public class EnumValueDefinition { + + public String name; + public int key; +} \ No newline at end of file diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/FieldDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/FieldDefinition.java new file mode 100644 index 0000000..3775a14 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/FieldDefinition.java @@ -0,0 +1,13 @@ +package com.peakup.tools.generator; + +import jakarta.annotation.Nullable; + +public class FieldDefinition { + + public String name; + public String javaType; + public boolean nullable; + public String columnName; + public @Nullable String columnDefinition; + public @Nullable String jdbcTypeCode; +} diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/ModelDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/ModelDefinition.java new file mode 100644 index 0000000..72827cc --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/ModelDefinition.java @@ -0,0 +1,20 @@ +package com.peakup.tools.generator; + +import java.util.ArrayList; +import java.util.List; + +public class ModelDefinition { + + public String modelName; + public String packageBase; + public String tableName; + /** "AbstractEntity" or "AbstractTimestampedEntity" */ + public String baseClass; + public String apiPath; + /** When true, adds @JsonInclude(JsonInclude.Include.NON_NULL) to the entity. */ + public boolean jsonIncludeNonNull; + public List fields = new ArrayList<>(); + public List elementCollections = new ArrayList<>(); + public List oneToManyRelations = new ArrayList<>(); + public List enums = new ArrayList<>(); +} \ No newline at end of file diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java b/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java new file mode 100644 index 0000000..8a6b073 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/ModelGeneratorTool.java @@ -0,0 +1,659 @@ +package com.peakup.tools.generator; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.squareup.javapoet.AnnotationSpec; +import com.squareup.javapoet.ArrayTypeName; +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.FieldSpec; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterSpec; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; + +import javax.lang.model.element.Modifier; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Generates a full domain slice (model, DAO, service, resource) from a JSON definition file. + * + *

Run from project root: + *

+ * JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7 mvn -f peakup-tools/pom.xml \
+ *   -s /home/ingo/.m2/settings_peak.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo \
+ *   compile exec:java -Dexec.mainClass=com.peakup.tools.generator.ModelGeneratorTool \
+ *   "-Dexec.args=/path/to/definition.json [outputDir]"
+ * 
+ * + *

Default outputDir: peakup-backend/src/main/java (relative to working directory). + */ +public class ModelGeneratorTool { + + private static final String COMMON_PKG = "com.peakup.common"; + + // ----------------------------------------------------------------------- + // Entry point + // ----------------------------------------------------------------------- + + public static void main(final String[] args) throws Exception { + if (args.length < 1) { + System.err.println("Usage: ModelGeneratorTool [outputDir]"); + System.exit(1); + } + + final Path definitionPath = Paths.get(args[0]); + final Path outputDir = args.length >= 2 + ? Paths.get(args[1]) + : Paths.get(System.getProperty("user.dir")).resolve("peakup-backend/src/main/java"); + + final ModelDefinition def = new ObjectMapper().readValue(definitionPath.toFile(), ModelDefinition.class); + + System.out.println("Generating: " + def.modelName + " → " + outputDir.toAbsolutePath()); + + final ModelGeneratorTool tool = new ModelGeneratorTool(); + final Set enumNames = def.enums.stream().map(e -> e.className).collect(Collectors.toSet()); + + for (final EnumDefinition enumDef : def.enums) { + write(tool.generateEnum(enumDef, def.packageBase + ".model"), outputDir, "model/" + enumDef.className); + } + for (final ElementCollectionDefinition ec : def.elementCollections) { + if (ec.embeddable != null) { + write(tool.generateEmbeddable(ec.embeddable, def.packageBase + ".model"), outputDir, "model/" + ec.embeddable.className); + } + } + write(tool.generateModel(def, enumNames), outputDir, "model/" + def.modelName); + write(tool.generateDAO(def), outputDir, "dao/" + def.modelName + "DAO"); + write(tool.generateService(def), outputDir, "service/" + def.modelName + "Service"); + write(tool.generateResource(def), outputDir, def.modelName + "Resource"); + + System.out.println("Done."); + } + + private static void write(final JavaFile file, final Path outputDir, final String label) throws Exception { + file.writeTo(outputDir); + System.out.println(" " + label + ".java"); + } + + // ----------------------------------------------------------------------- + // Enum + // ----------------------------------------------------------------------- + + private JavaFile generateEnum(final EnumDefinition enumDef, final String modelPackage) { + final ClassName selfClass = ClassName.get(modelPackage, enumDef.className); + final ClassName persistableEnum = ClassName.get(COMMON_PKG + ".model", "PersistableEnum"); + final ClassName abstractConverter = ClassName.get(COMMON_PKG + ".model", "AbstractEnumConverter"); + + final TypeSpec.Builder enumBuilder = TypeSpec.enumBuilder(enumDef.className) + .addModifiers(Modifier.PUBLIC) + .addSuperinterface(ParameterizedTypeName.get(persistableEnum, selfClass)); + + for (final EnumValueDefinition value : enumDef.values) { + enumBuilder.addEnumConstant(value.name, TypeSpec.anonymousClassBuilder("$L", value.key).build()); + } + + enumBuilder.addField(FieldSpec.builder(TypeName.INT, "key", Modifier.PRIVATE, Modifier.FINAL).build()); + + enumBuilder.addMethod(MethodSpec.constructorBuilder() + .addParameter(TypeName.INT, "key") + .addStatement("this.key = key") + .build()); + + enumBuilder.addMethod(MethodSpec.methodBuilder("getKey") + .addAnnotation(Override.class) + .addModifiers(Modifier.PUBLIC) + .returns(ClassName.get(Integer.class)) + .addStatement("return key") + .build()); + + final TypeSpec converterClass = TypeSpec.classBuilder("Converter") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "Converter")) + .addMember("autoApply", "$L", true) + .build()) + .superclass(ParameterizedTypeName.get(abstractConverter, selfClass)) + .addMethod(MethodSpec.methodBuilder("getEnumByKey") + .addAnnotation(Override.class) + .addModifiers(Modifier.PUBLIC) + .returns(selfClass) + .addParameter(ParameterSpec.builder(ClassName.get(Integer.class), "key").addModifiers(Modifier.FINAL).build()) + .addStatement("return $T.getEnumByKey(key, $T.values())", persistableEnum, selfClass) + .build()) + .build(); + + enumBuilder.addType(converterClass); + + return JavaFile.builder(modelPackage, enumBuilder.build()).skipJavaLangImports(true).build(); + } + + // ----------------------------------------------------------------------- + // Embeddable + // ----------------------------------------------------------------------- + + private JavaFile generateEmbeddable(final EmbeddableDefinition embeddable, final String modelPackage) { + final TypeSpec.Builder classBuilder = TypeSpec.classBuilder(embeddable.className) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(ClassName.get("jakarta.persistence", "Embeddable")) + .addAnnotation(ClassName.get("lombok", "Data")); + + for (final FieldDefinition field : embeddable.fields) { + classBuilder.addField(buildColumnField(field, modelPackage, Set.of())); + } + + classBuilder.addMethod(buildHibernateConstructor()); + + return JavaFile.builder(modelPackage, classBuilder.build()).skipJavaLangImports(true).build(); + } + + // ----------------------------------------------------------------------- + // Entity model + // ----------------------------------------------------------------------- + + private JavaFile generateModel(final ModelDefinition def, final Set enumNames) { + final String modelPackage = def.packageBase + ".model"; + final ClassName modelClass = ClassName.get(modelPackage, def.modelName); + + final TypeSpec.Builder classBuilder = TypeSpec.classBuilder(def.modelName) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(ClassName.get("jakarta.persistence", "Entity")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "Table")) + .addMember("name", "$S", def.tableName) + .build()) + .addAnnotation(ClassName.get("lombok", "Data")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("lombok", "EqualsAndHashCode")) + .addMember("callSuper", "$L", true) + .build()) + .superclass(ClassName.get(COMMON_PKG + ".model", def.baseClass)); + + if (def.jsonIncludeNonNull) { + classBuilder.addAnnotation(AnnotationSpec.builder( + ClassName.get("com.fasterxml.jackson.annotation", "JsonInclude")) + .addMember("value", "$T.NON_NULL", + ClassName.get("com.fasterxml.jackson.annotation", "JsonInclude", "Include")) + .build()); + } + + for (final FieldDefinition field : def.fields) { + classBuilder.addField(buildColumnField(field, modelPackage, enumNames)); + } + addElementCollectionFields(classBuilder, def, modelPackage); + addOneToManyFields(classBuilder, def, modelPackage); + classBuilder.addMethod(buildMergeFromMethod(def, modelClass)); + classBuilder.addMethod(buildHibernateConstructor()); + + return JavaFile.builder(modelPackage, classBuilder.build()).skipJavaLangImports(true).build(); + } + + private void addElementCollectionFields(final TypeSpec.Builder classBuilder, + final ModelDefinition def, final String modelPackage) { + for (final ElementCollectionDefinition ec : def.elementCollections) { + final TypeName elementType = "String".equals(ec.valueType) + ? ClassName.get(String.class) + : ClassName.get(modelPackage, ec.valueType); + + final FieldSpec.Builder fieldBuilder = FieldSpec.builder( + ParameterizedTypeName.get(ClassName.get(List.class), elementType), + ec.fieldName, Modifier.PUBLIC) + .addAnnotation(ClassName.get("com.fasterxml.jackson.annotation", "JsonIgnore")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "ElementCollection")) + .addMember("fetch", "$T.LAZY", ClassName.get("jakarta.persistence", "FetchType")) + .build()) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "CollectionTable")) + .addMember("name", "$S", ec.collectionTable) + .addMember("joinColumns", "@$T(name = $S)", + ClassName.get("jakarta.persistence", "JoinColumn"), ec.joinColumn) + .build()) + .initializer("new $T<>()", ArrayList.class); + + if (ec.orderColumn != null) { + fieldBuilder.addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "OrderColumn")) + .addMember("name", "$S", ec.orderColumn) + .build()); + } + if (ec.valueColumn != null) { + fieldBuilder.addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "Column")) + .addMember("name", "$S", ec.valueColumn) + .build()); + } + classBuilder.addField(fieldBuilder.build()); + } + } + + private void addOneToManyFields(final TypeSpec.Builder classBuilder, + final ModelDefinition def, final String modelPackage) { + for (final OneToManyDefinition otm : def.oneToManyRelations) { + final ClassName entityClass = resolveClassName(otm.entityClass, modelPackage); + + final FieldSpec.Builder fieldBuilder = FieldSpec.builder( + ParameterizedTypeName.get(ClassName.get(List.class), entityClass), + otm.fieldName, Modifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "OneToMany")) + .addMember("fetch", "$T.EAGER", ClassName.get("jakarta.persistence", "FetchType")) + .build()) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "JoinColumn")) + .addMember("name", "$S", otm.joinColumn) + .build()) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.persistence", "OrderBy")) + .addMember("value", "$S", otm.orderBy) + .build()) + .initializer("new $T<>()", ArrayList.class); + + if (otm.fetchMode != null) { + fieldBuilder.addAnnotation(AnnotationSpec.builder(ClassName.get("org.hibernate.annotations", "Fetch")) + .addMember("value", "$T.$L", + ClassName.get("org.hibernate.annotations", "FetchMode"), otm.fetchMode) + .build()); + } + classBuilder.addField(fieldBuilder.build()); + } + } + + private MethodSpec buildMergeFromMethod(final ModelDefinition def, final ClassName modelClass) { + final MethodSpec.Builder builder = MethodSpec.methodBuilder("mergeFrom") + .addModifiers(Modifier.PUBLIC) + .returns(TypeName.VOID) + .addParameter(ParameterSpec.builder(modelClass, "source").addModifiers(Modifier.FINAL).build()); + for (final FieldDefinition field : def.fields) { + builder.addStatement("this.$L = source.$L", field.name, field.name); + } + return builder.build(); + } + + // ----------------------------------------------------------------------- + // DAO + // ----------------------------------------------------------------------- + + private JavaFile generateDAO(final ModelDefinition def) { + final String modelPackage = def.packageBase + ".model"; + final String daoPackage = def.packageBase + ".dao"; + final ClassName modelClass = ClassName.get(modelPackage, def.modelName); + + final TypeSpec.Builder classBuilder = TypeSpec.classBuilder(def.modelName + "DAO") + .addModifiers(Modifier.PUBLIC) + .addAnnotation(ClassName.get("jakarta.enterprise.context", "ApplicationScoped")) + .superclass(ParameterizedTypeName.get( + ClassName.get(COMMON_PKG + ".dao", "AbstractDAO"), modelClass)); + + classBuilder.addMethod(MethodSpec.methodBuilder("getEntityClass") + .addAnnotation(Override.class) + .addModifiers(Modifier.PROTECTED) + .returns(ParameterizedTypeName.get(ClassName.get(Class.class), modelClass)) + .addStatement("return $T.class", modelClass) + .build()); + + if (hasAthleteId(def)) { + classBuilder.addMethod(buildGetByAthleteIdDAO(modelClass)); + } + + return JavaFile.builder(daoPackage, classBuilder.build()).skipJavaLangImports(true).build(); + } + + private MethodSpec buildGetByAthleteIdDAO(final ClassName modelClass) { + return MethodSpec.methodBuilder("getByAthleteId") + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(ClassName.get(List.class), modelClass)) + .addParameter(ParameterSpec.builder(TypeName.LONG, "athleteId").addModifiers(Modifier.FINAL).build()) + .addStatement("final $T cb = em.getCriteriaBuilder()", + ClassName.get("jakarta.persistence.criteria", "CriteriaBuilder")) + .addStatement("final $T<$T> cq = cb.createQuery($T.class)", + ClassName.get("jakarta.persistence.criteria", "CriteriaQuery"), modelClass, modelClass) + .addStatement("final $T<$T> root = cq.from($T.class)", + ClassName.get("jakarta.persistence.criteria", "Root"), modelClass, modelClass) + .addStatement("cq.select(root).where(cb.equal(root.get($S), athleteId))", "athleteId") + .addStatement("return em.createQuery(cq).getResultList()") + .build(); + } + + // ----------------------------------------------------------------------- + // Service + // ----------------------------------------------------------------------- + + private JavaFile generateService(final ModelDefinition def) { + final String modelPackage = def.packageBase + ".model"; + final String daoPackage = def.packageBase + ".dao"; + final String servicePackage = def.packageBase + ".service"; + final ClassName modelClass = ClassName.get(modelPackage, def.modelName); + final ClassName daoClass = ClassName.get(daoPackage, def.modelName + "DAO"); + final ClassName transactional = ClassName.get("jakarta.transaction", "Transactional"); + final String daoFieldName = toLowerFirst(def.modelName) + "DAO"; + final String entityParam = toLowerFirst(def.modelName); + + final TypeSpec.Builder classBuilder = TypeSpec.classBuilder(def.modelName + "Service") + .addModifiers(Modifier.PUBLIC) + .addAnnotation(ClassName.get("jakarta.enterprise.context", "ApplicationScoped")) + .superclass(ParameterizedTypeName.get( + ClassName.get(COMMON_PKG + ".service", "AbstractService"), modelClass, daoClass)); + + classBuilder.addField(FieldSpec.builder(daoClass, daoFieldName) + .addAnnotation(ClassName.get("jakarta.inject", "Inject")) + .addAnnotation(suppressNullAwayInit()) + .build()); + + classBuilder.addMethod(MethodSpec.methodBuilder("getDAO") + .addAnnotation(Override.class) + .addModifiers(Modifier.PROTECTED) + .returns(daoClass) + .addStatement("return $L", daoFieldName) + .build()); + + classBuilder.addMethod(MethodSpec.methodBuilder("getById") + .addAnnotation(Override.class) + .addAnnotation(transactional) + .addModifiers(Modifier.PUBLIC) + .returns(modelClass) + .addParameter(ParameterSpec.builder(TypeName.LONG, "id").addModifiers(Modifier.FINAL).build()) + .addStatement("return super.getById(id)") + .build()); + + classBuilder.addMethod(MethodSpec.methodBuilder("getAll") + .addAnnotation(Override.class) + .addAnnotation(transactional) + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(ClassName.get(List.class), modelClass)) + .addStatement("return super.getAll()") + .build()); + + classBuilder.addMethod(MethodSpec.methodBuilder("insert") + .addAnnotation(Override.class) + .addAnnotation(transactional) + .addModifiers(Modifier.PUBLIC) + .addParameter(ParameterSpec.builder(modelClass, entityParam).addModifiers(Modifier.FINAL).build()) + .returns(TypeName.VOID) + .addStatement("super.insert($L)", entityParam) + .build()); + + classBuilder.addMethod(MethodSpec.methodBuilder("update") + .addAnnotation(Override.class) + .addAnnotation(transactional) + .addModifiers(Modifier.PUBLIC) + .addParameter(ParameterSpec.builder(modelClass, entityParam).addModifiers(Modifier.FINAL).build()) + .returns(TypeName.VOID) + .addStatement("super.update($L)", entityParam) + .build()); + + classBuilder.addMethod(MethodSpec.methodBuilder("delete") + .addAnnotation(Override.class) + .addAnnotation(transactional) + .addModifiers(Modifier.PUBLIC) + .addParameter(ParameterSpec.builder(modelClass, entityParam).addModifiers(Modifier.FINAL).build()) + .returns(TypeName.VOID) + .addStatement("super.delete($L)", entityParam) + .build()); + + if (hasAthleteId(def)) { + classBuilder.addMethod(MethodSpec.methodBuilder("getByAthleteId") + .addAnnotation(transactional) + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(ClassName.get(List.class), modelClass)) + .addParameter(ParameterSpec.builder(TypeName.LONG, "athleteId").addModifiers(Modifier.FINAL).build()) + .addStatement("return $L.getByAthleteId(athleteId)", daoFieldName) + .build()); + } + + return JavaFile.builder(servicePackage, classBuilder.build()).skipJavaLangImports(true).build(); + } + + // ----------------------------------------------------------------------- + // Resource + // ----------------------------------------------------------------------- + + private JavaFile generateResource(final ModelDefinition def) { + final String modelPackage = def.packageBase + ".model"; + final String servicePackage = def.packageBase + ".service"; + final ClassName modelClass = ClassName.get(modelPackage, def.modelName); + final ClassName serviceClass = ClassName.get(servicePackage, def.modelName + "Service"); + final ClassName restResponse = ClassName.get("org.jboss.resteasy.reactive", "RestResponse"); + final ClassName noResultException = ClassName.get("jakarta.persistence", "NoResultException"); + final ClassName response = ClassName.get("jakarta.ws.rs.core", "Response"); + final ClassName mediaType = ClassName.get("jakarta.ws.rs.core", "MediaType"); + final String serviceFieldName = toLowerFirst(def.modelName) + "Service"; + final String entityParam = toLowerFirst(def.modelName); + + final TypeSpec.Builder classBuilder = TypeSpec.classBuilder(def.modelName + "Resource") + .addModifiers(Modifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "Path")) + .addMember("value", "$S", def.apiPath) + .build()) + .addAnnotation(ClassName.get("jakarta.enterprise.context", "ApplicationScoped")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "Produces")) + .addMember("value", "$T.APPLICATION_JSON", mediaType) + .build()) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "Consumes")) + .addMember("value", "$T.APPLICATION_JSON", mediaType) + .build()) + .superclass(ClassName.get(COMMON_PKG, "AbstractResource")); + + classBuilder.addField(FieldSpec.builder(serviceClass, serviceFieldName) + .addAnnotation(ClassName.get("jakarta.inject", "Inject")) + .addAnnotation(suppressNullAwayInit()) + .build()); + + addGetEndpoint(classBuilder, def, modelClass, serviceClass, serviceFieldName, restResponse); + + classBuilder.addMethod(buildGetByIdEndpoint(modelClass, serviceFieldName, restResponse, noResultException)); + classBuilder.addMethod(buildCreateEndpoint(modelClass, serviceFieldName, entityParam, restResponse, response)); + classBuilder.addMethod(buildUpdateEndpoint(modelClass, serviceFieldName, entityParam, restResponse, noResultException)); + classBuilder.addMethod(buildDeleteEndpoint(modelClass, serviceFieldName, restResponse, noResultException)); + + return JavaFile.builder(def.packageBase, classBuilder.build()).skipJavaLangImports(true).build(); + } + + private void addGetEndpoint(final TypeSpec.Builder classBuilder, final ModelDefinition def, + final ClassName modelClass, final ClassName serviceClass, + final String serviceFieldName, final ClassName restResponse) { + final TypeName listResponse = ParameterizedTypeName.get(restResponse, + ParameterizedTypeName.get(ClassName.get(List.class), modelClass)); + + if (hasAthleteId(def)) { + classBuilder.addMethod(MethodSpec.methodBuilder("getByAthlete") + .addAnnotation(ClassName.get("jakarta.ws.rs", "GET")) + .addModifiers(Modifier.PUBLIC) + .returns(listResponse) + .addParameter(ParameterSpec.builder(ClassName.get(Long.class), "athleteId") + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "QueryParam")) + .addMember("value", "$S", "athleteId").build()) + .addAnnotation(ClassName.get("jakarta.annotation", "Nullable")) + .addModifiers(Modifier.FINAL) + .build()) + .beginControlFlow("if (athleteId == null)") + .addStatement("throw new $T($S)", + ClassName.get("jakarta.ws.rs", "BadRequestException"), + "athleteId query parameter is required") + .endControlFlow() + .addStatement("return $T.ok($L.getByAthleteId(athleteId))", restResponse, serviceFieldName) + .build()); + } else { + classBuilder.addMethod(MethodSpec.methodBuilder("getAll") + .addAnnotation(ClassName.get("jakarta.ws.rs", "GET")) + .addModifiers(Modifier.PUBLIC) + .returns(listResponse) + .addStatement("return $T.ok($L.getAll())", restResponse, serviceFieldName) + .build()); + } + } + + private MethodSpec buildGetByIdEndpoint(final ClassName modelClass, final String serviceFieldName, + final ClassName restResponse, final ClassName noResultException) { + return MethodSpec.methodBuilder("getById") + .addAnnotation(ClassName.get("jakarta.ws.rs", "GET")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "Path")) + .addMember("value", "$S", "/{id}").build()) + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(restResponse, modelClass)) + .addParameter(buildIdPathParam()) + .beginControlFlow("try") + .addStatement("return $T.ok($L.getById(id))", restResponse, serviceFieldName) + .nextControlFlow("catch (final $T e)", noResultException) + .addStatement("return $T.notFound()", restResponse) + .endControlFlow() + .build(); + } + + private MethodSpec buildCreateEndpoint(final ClassName modelClass, final String serviceFieldName, + final String entityParam, final ClassName restResponse, + final ClassName response) { + return MethodSpec.methodBuilder("create") + .addAnnotation(ClassName.get("jakarta.ws.rs", "POST")) + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(restResponse, modelClass)) + .addParameter(ParameterSpec.builder(modelClass, entityParam).addModifiers(Modifier.FINAL).build()) + .addStatement("$L.insert($L)", serviceFieldName, entityParam) + .addStatement("return $T.status($T.Status.CREATED, $L)", restResponse, response, entityParam) + .build(); + } + + private MethodSpec buildUpdateEndpoint(final ClassName modelClass, final String serviceFieldName, + final String entityParam, final ClassName restResponse, + final ClassName noResultException) { + return MethodSpec.methodBuilder("update") + .addAnnotation(ClassName.get("jakarta.ws.rs", "PUT")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "Path")) + .addMember("value", "$S", "/{id}").build()) + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(restResponse, modelClass)) + .addParameter(buildIdPathParam()) + .addParameter(ParameterSpec.builder(modelClass, entityParam).addModifiers(Modifier.FINAL).build()) + .beginControlFlow("try") + .addStatement("final $T existing = $L.getById(id)", modelClass, serviceFieldName) + .addStatement("existing.mergeFrom($L)", entityParam) + .addStatement("$L.update(existing)", serviceFieldName) + .addStatement("return $T.ok(existing)", restResponse) + .nextControlFlow("catch (final $T e)", noResultException) + .addStatement("return $T.notFound()", restResponse) + .endControlFlow() + .build(); + } + + private MethodSpec buildDeleteEndpoint(final ClassName modelClass, final String serviceFieldName, + final ClassName restResponse, final ClassName noResultException) { + return MethodSpec.methodBuilder("delete") + .addAnnotation(ClassName.get("jakarta.ws.rs", "DELETE")) + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "Path")) + .addMember("value", "$S", "/{id}").build()) + .addModifiers(Modifier.PUBLIC) + .returns(ParameterizedTypeName.get(restResponse, ClassName.get(Void.class))) + .addParameter(buildIdPathParam()) + .beginControlFlow("try") + .addStatement("final $T existing = $L.getById(id)", modelClass, serviceFieldName) + .addStatement("$L.delete(existing)", serviceFieldName) + .addStatement("return $T.noContent()", restResponse) + .nextControlFlow("catch (final $T e)", noResultException) + .addStatement("return $T.notFound()", restResponse) + .endControlFlow() + .build(); + } + + // ----------------------------------------------------------------------- + // Shared field builders + // ----------------------------------------------------------------------- + + private FieldSpec buildColumnField(final FieldDefinition field, final String modelPackage, + final Set enumNames) { + final TypeName typeName = resolveTypeName(field.javaType, modelPackage, enumNames); + + final AnnotationSpec.Builder colAnn = AnnotationSpec.builder(ClassName.get("jakarta.persistence", "Column")) + .addMember("name", "$S", field.columnName); + if (!field.nullable && !isPrimitive(field.javaType)) { + colAnn.addMember("nullable", "$L", false); + } + if (field.columnDefinition != null) { + colAnn.addMember("columnDefinition", "$S", field.columnDefinition); + } + + final FieldSpec.Builder fieldBuilder = FieldSpec.builder(typeName, field.name, Modifier.PUBLIC) + .addAnnotation(colAnn.build()); + + if (field.nullable) { + fieldBuilder.addAnnotation(ClassName.get("jakarta.annotation", "Nullable")); + } + if ("ARRAY".equals(field.jdbcTypeCode)) { + fieldBuilder.addAnnotation(AnnotationSpec.builder(ClassName.get("org.hibernate.annotations", "JdbcTypeCode")) + .addMember("value", "$T.ARRAY", ClassName.get("org.hibernate.type", "SqlTypes")) + .build()); + } + return fieldBuilder.build(); + } + + private MethodSpec buildHibernateConstructor() { + return MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(SuppressWarnings.class) + .addMember("value", "$S", "NullAway.Init") + .build()) + .addComment("hibernate") + .build(); + } + + private ParameterSpec buildIdPathParam() { + return ParameterSpec.builder(TypeName.LONG, "id") + .addAnnotation(AnnotationSpec.builder(ClassName.get("jakarta.ws.rs", "PathParam")) + .addMember("value", "$S", "id").build()) + .addModifiers(Modifier.FINAL) + .build(); + } + + private AnnotationSpec suppressNullAwayInit() { + return AnnotationSpec.builder(SuppressWarnings.class) + .addMember("value", "$S", "NullAway.Init") + .build(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static TypeName resolveTypeName(final String javaType, final String modelPackage, + final Set localTypes) { + return switch (javaType) { + case "int" -> TypeName.INT; + case "long" -> TypeName.LONG; + case "double" -> TypeName.DOUBLE; + case "float" -> TypeName.FLOAT; + case "boolean" -> TypeName.BOOLEAN; + case "Integer" -> ClassName.get(Integer.class); + case "Long" -> ClassName.get(Long.class); + case "Double" -> ClassName.get(Double.class); + case "Float" -> ClassName.get(Float.class); + case "Boolean" -> ClassName.get(Boolean.class); + case "String" -> ClassName.get(String.class); + case "int[]" -> ArrayTypeName.of(TypeName.INT); + case "long[]" -> ArrayTypeName.of(TypeName.LONG); + case "double[]" -> ArrayTypeName.of(TypeName.DOUBLE); + case "String[]" -> ArrayTypeName.of(ClassName.get(String.class)); + case "Instant" -> ClassName.get("java.time", "Instant"); + case "LocalDate" -> ClassName.get("java.time", "LocalDate"); + default -> resolveClassName(javaType, modelPackage); + }; + } + + private static ClassName resolveClassName(final String className, final String modelPackage) { + if (className.contains(".")) { + final int lastDot = className.lastIndexOf('.'); + return ClassName.get(className.substring(0, lastDot), className.substring(lastDot + 1)); + } + return ClassName.get(modelPackage, className); + } + + private static boolean isPrimitive(final String javaType) { + return switch (javaType) { + case "int", "long", "double", "float", "boolean" -> true; + default -> false; + }; + } + + private static boolean hasAthleteId(final ModelDefinition def) { + return def.fields.stream().anyMatch(f -> "athleteId".equals(f.name)); + } + + private static String toLowerFirst(final String name) { + if (name == null || name.isEmpty()) { + return name; + } + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } +} \ No newline at end of file diff --git a/peakup-tools/src/main/java/com/peakup/tools/generator/OneToManyDefinition.java b/peakup-tools/src/main/java/com/peakup/tools/generator/OneToManyDefinition.java new file mode 100644 index 0000000..5cb56b4 --- /dev/null +++ b/peakup-tools/src/main/java/com/peakup/tools/generator/OneToManyDefinition.java @@ -0,0 +1,17 @@ +package com.peakup.tools.generator; + +import jakarta.annotation.Nullable; + +public class OneToManyDefinition { + + public String fieldName; + /** + * Simple or fully-qualified class name of the target entity. + * Simple names are resolved against the model package; FQNs are used as-is. + */ + public String entityClass; + public String joinColumn; + public String orderBy; + /** Hibernate FetchMode constant name, e.g. "SUBSELECT". Null omits @Fetch. */ + public @Nullable String fetchMode; +} \ No newline at end of file diff --git a/peakup-tools/src/main/resources/example-model-definition.json b/peakup-tools/src/main/resources/example-model-definition.json new file mode 100644 index 0000000..ac09786 --- /dev/null +++ b/peakup-tools/src/main/resources/example-model-definition.json @@ -0,0 +1,60 @@ +{ + "modelName": "TrainingNote", + "packageBase": "com.peakup.trainingnote", + "tableName": "training_note", + "baseClass": "AbstractTimestampedEntity", + "apiPath": "/api/training-notes", + "jsonIncludeNonNull": false, + "fields": [ + { "name": "athleteId", "javaType": "Long", "nullable": false, "columnName": "athlete_id" }, + { "name": "title", "javaType": "String", "nullable": false, "columnName": "title" }, + { "name": "body", "javaType": "String", "nullable": true, "columnName": "body", "columnDefinition": "TEXT" }, + { "name": "noteType", "javaType": "NoteTypeEnum", "nullable": false, "columnName": "note_type" }, + { "name": "streamTypes", "javaType": "String[]", "nullable": true, "columnName": "stream_types", "columnDefinition": "text[]", "jdbcTypeCode": "ARRAY" } + ], + "elementCollections": [ + { + "fieldName": "tags", + "collectionTable": "training_note_tag", + "joinColumn": "training_note_id", + "valueType": "String", + "valueColumn": "tag", + "orderColumn": null, + "embeddable": null + }, + { + "fieldName": "attachments", + "collectionTable": "training_note_attachment", + "joinColumn": "training_note_id", + "valueType": "NoteAttachment", + "valueColumn": null, + "orderColumn": "sort_index", + "embeddable": { + "className": "NoteAttachment", + "fields": [ + { "name": "url", "javaType": "String", "nullable": false, "columnName": "url" }, + { "name": "filename", "javaType": "String", "nullable": true, "columnName": "filename" } + ] + } + } + ], + "oneToManyRelations": [ + { + "fieldName": "steps", + "entityClass": "NoteStep", + "joinColumn": "training_note_id", + "orderBy": "sortIndex ASC", + "fetchMode": "SUBSELECT" + } + ], + "enums": [ + { + "className": "NoteTypeEnum", + "values": [ + { "name": "GENERAL", "key": 1 }, + { "name": "RACE", "key": 2 }, + { "name": "RECOVERY", "key": 3 } + ] + } + ] +}