Skip to content

Feat : Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project#3742

Open
ZARAYACH wants to merge 30 commits intomicronaut-projects:5.0.xfrom
ZARAYACH:jpaMetamodel
Open

Feat : Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project#3742
ZARAYACH wants to merge 30 commits intomicronaut-projects:5.0.xfrom
ZARAYACH:jpaMetamodel

Conversation

@ZARAYACH
Copy link
Copy Markdown
Contributor

@ZARAYACH ZARAYACH commented Mar 4, 2026

This Pr adds support for generating StaticMetamodel for Jakarta persistence entities using the Micronaut sourcegen project .
The output is a class_ annotated with @jakarta.persistence.metamodel.StaticMetamodel with the correct attributes based on the actual class . exemple of generted Staticmetamodel class_ :

package test;

import jakarta.annotation.Generated;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.SingularAttribute;
import jakarta.persistence.metamodel.StaticMetamodel;
import java.lang.Long;
import java.lang.String;

@StaticMetamodel(Parent.class)
@Generated("io.micronaut.data.processor.jpa.metamodel.JpaMetamodelProcessor")
public abstract class Parent_ {
 public static final String ID = "id";

 public static final String NAME = "name";

 public static volatile SingularAttribute<Parent, Long> id;

 public static volatile SingularAttribute<Parent, String> name;

 public static volatile EntityType<Parent> class_;
}
package test;

import jakarta.annotation.Generated;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.SingularAttribute;
import jakarta.persistence.metamodel.StaticMetamodel;
import java.lang.Long;
import java.lang.String;

@StaticMetamodel(Child.class)
@Generated("io.micronaut.data.processor.jpa.metamodel.JpaMetamodelProcessor")
public abstract class Child_ extends Parent_ {
 public static final String AGE = "age";

 public static volatile SingularAttribute<Child, Long> age;

 public static volatile EntityType<Child> class_;
}

@dstepanov please can you review this pr, and give me your feedback, Thank you.

@ZARAYACH ZARAYACH changed the title Jpa metamodel Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project Mar 4, 2026
Copy link
Copy Markdown
Contributor

@dstepanov dstepanov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR needs tests for all possible scenarios. Create a testing project on the examples folder. You can use Hibernate and it's Criteria to validate that the metamodel is correct.

Map<String, ClassElement> typeArguments = beanPropertyType.getTypeArguments();

TypeDef typeDef = switch (beanPropertyType.getName()) {
case "java.util.Collection" ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make those constants and use the original class name COLLECTION = Collection.class.getName()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 5efbb20

@radovanradic
Copy link
Copy Markdown
Contributor

Maybe could add some documentation for the new module?

@radovanradic
Copy link
Copy Markdown
Contributor

Maybe could add some documentation for the new module?

I meant new .adoc file, something like this https://micronaut-projects.github.io/micronaut-data/latest/guide/#typeSafeJava if make sense for new module

@ZARAYACH
Copy link
Copy Markdown
Contributor Author

Maybe could add some documentation for the new module?

I meant new .adoc file, something like this https://micronaut-projects.github.io/micronaut-data/latest/guide/#typeSafeJava if make sense for new module

Yep, working on it :)

@ZARAYACH ZARAYACH marked this pull request as ready for review March 24, 2026 17:16
@ZARAYACH
Copy link
Copy Markdown
Contributor Author

Maybe could add some documentation for the new module?

I meant new .adoc file, something like this https://micronaut-projects.github.io/micronaut-data/latest/guide/#typeSafeJava if make sense for new module

Yep, working on it :)

Hi @radovanradic , i've added some docs, basically how to use it and disable it , i don't know if it's enough or should i go in details on how it is implemented.
Thanks.

@ZARAYACH ZARAYACH requested a review from radovanradic March 25, 2026 08:29
@radovanradic
Copy link
Copy Markdown
Contributor

radovanradic commented Mar 25, 2026

Maybe could add some documentation for the new module?

I meant new .adoc file, something like this https://micronaut-projects.github.io/micronaut-data/latest/guide/#typeSafeJava if make sense for new module

Yep, working on it :)

Hi @radovanradic , i've added some docs, basically how to use it and disable it , i don't know if it's enough or should i go in details on how it is implemented. Thanks.

I think this is fine.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Micronaut Data support for generating Jakarta Persistence static metamodel (*_) classes via Micronaut SourceGen, along with new documentation and a comprehensive doc-example module validating Criteria API usage.

Changes:

  • Introduces a new data-jpa-metamodel-processor module implementing a TypeElementVisitor that generates @StaticMetamodel sources.
  • Updates the type-safe Criteria documentation to describe Micronaut vs Hibernate metamodel generation and how to disable Micronaut generation.
  • Adds a new doc-examples:data-jpa-metamodel-example-java module with entities/repositories/tests exercising generated metamodels (including collections, embeddables, and inheritance).

Reviewed changes

Copilot reviewed 46 out of 46 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
src/main/docs/guide/shared/criteriaSpecifications/typeSafeJava.adoc Expands/clarifies Criteria metamodel generation configuration and disabling behavior
settings.gradle Adds new processor module + new doc example module to the multi-project build
gradle/libs.versions.toml Enables Micronaut SourceGen BOM/version for the new processor
doc-examples/data-jpa-metamodel-example-java/build.gradle New example build config incl. processor wiring and “disabled generation” verification task
doc-examples/data-jpa-metamodel-example-java/src/main/resources/application.properties Datasource/JPA config for the new example app
doc-examples/data-jpa-metamodel-example-java/src/main/java/** New example entities + repositories used by Criteria tests
doc-examples/data-jpa-metamodel-example-java/src/test/java/** Criteria API tests validating generated metamodel shape and usage
data-jpa-metamodel-processor/src/main/java/** New metamodel generator implementation (visitor + generator utilities)
data-jpa-metamodel-processor/src/test/groovy/** Processor tests validating generated metamodel structure/typing
data-jpa-metamodel-processor/src/main/resources/** Registers the new TypeElementVisitor via service loader

Comment on lines +58 to +59
// Logging addition logic
System.out.println("Salary updated: " + salary);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid writing to stdout from entity setters. This will spam test output and can cause unexpected side effects when JPA calls setters; use the project logging framework (or remove the logging) instead of System.out.println().

Suggested change
// Logging addition logic
System.out.println("Salary updated: " + salary);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

Comment on lines +50 to +54
dependsOn("clean")
source = sourceSets.main.java
classpath = sourceSets.main.compileClasspath
destinationDirectory = disabledClassesDir

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having compileMetamodelDisabled depend on clean makes every test execution run clean first (slow, breaks incremental builds, and can surprise IDE runs). Instead, delete only the task's own output dirs or configure the task outputs so it is reproducible without forcing a full clean.

Suggested change
dependsOn("clean")
source = sourceSets.main.java
classpath = sourceSets.main.compileClasspath
destinationDirectory = disabledClassesDir
// Clean only this task's outputs instead of depending on the global 'clean' task
doFirst {
delete(disabledGenDir)
delete(disabledClassesDir)
}
source = sourceSets.main.java
classpath = sourceSets.main.compileClasspath
destinationDirectory = disabledClassesDir
// Declare the generated metamodel directory as an output of this task
outputs.dir(disabledGenDir)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

Comment on lines +237 to +244
private static JakartaAccessType resolveAccessType(@NonNull ClassElement element, @Nullable AnnotationValue<Annotation> jakartaAccessAnnotation) {
if (jakartaAccessAnnotation == null && element.getMethods().stream().anyMatch(o -> o.hasAnnotation(JAKARTA_ID))) {
return JakartaAccessType.PROPERTY;
}
if (jakartaAccessAnnotation == null) {
return JakartaAccessType.FIELD;
}
return jakartaAccessAnnotation.getRequiredValue(JakartaAccessType.class);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Access type inference only checks for @jakarta.persistence.Id on methods. Entities that use @EmbeddedId on a getter should also imply PROPERTY access; otherwise the processor may incorrectly inspect fields and generate the wrong metamodel. Consider checking for @EmbeddedId (and/or treating either annotation as an identifier marker).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

@ZARAYACH ZARAYACH Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 514846b

*/
private static FieldDef createEntityTypeField(ClassTypeDef elementType) {
return FieldDef.builder("class_").addModifiers(Modifier.PUBLIC, Modifier.VOLATILE, Modifier.STATIC)
.ofType(TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_ENTITY_TYPE), elementType)).build();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated class_ field is always typed as EntityType. For @MappedSuperclass and @Embeddable metamodels, this should be MappedSuperclassType / EmbeddableType (or a common supertype like ManagedType) to match the Jakarta metamodel API and avoid incorrect typing.

Suggested change
.ofType(TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_ENTITY_TYPE), elementType)).build();
.ofType(TypeDef.parameterized(ClassTypeDef.of("jakarta.persistence.metamodel.ManagedType"), elementType)).build();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

Comment on lines +309 to +326
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_COLLECTION_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("E"))));
} else if (fieldTypeName.equals(JAVA_UTIL_SET)) {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_SET_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("E"))));
} else if (fieldTypeName.equals(JAVA_UTIL_LIST)) {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_LIST_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("E"))));
} else if (fieldTypeName.equals(JAVA_UTIL_MAP)) {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_MAP_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("K"))),
TypeDef.of(Objects.requireNonNull(typeArguments.get("V"))));
} else {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_SINGULAR_ATTRIBUTE), classTypeDef, getProperType(TypeDef.of(fieldType)));
}
return attributeDefBuilder.ofType(typeDef).build();
}

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createAttributeField() uses Objects.requireNonNull on generic type arguments (E/K/V). If the user declares a raw Collection/Map (no generics), this will throw an NPE and surface as a vague "Failed to generate" error. Consider defaulting missing type args to Object, or throwing a ProcessingException with a clear message.

Suggested change
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_COLLECTION_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("E"))));
} else if (fieldTypeName.equals(JAVA_UTIL_SET)) {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_SET_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("E"))));
} else if (fieldTypeName.equals(JAVA_UTIL_LIST)) {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_LIST_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("E"))));
} else if (fieldTypeName.equals(JAVA_UTIL_MAP)) {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_MAP_ATTRIBUTE), classTypeDef,
TypeDef.of(Objects.requireNonNull(typeArguments.get("K"))),
TypeDef.of(Objects.requireNonNull(typeArguments.get("V"))));
} else {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_SINGULAR_ATTRIBUTE), classTypeDef, getProperType(TypeDef.of(fieldType)));
}
return attributeDefBuilder.ofType(typeDef).build();
}
ClassElement elementType = getRequiredTypeArgument(typeArguments, "E", fieldName, fieldTypeName);
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_COLLECTION_ATTRIBUTE), classTypeDef,
TypeDef.of(elementType));
} else if (fieldTypeName.equals(JAVA_UTIL_SET)) {
ClassElement elementType = getRequiredTypeArgument(typeArguments, "E", fieldName, fieldTypeName);
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_SET_ATTRIBUTE), classTypeDef,
TypeDef.of(elementType));
} else if (fieldTypeName.equals(JAVA_UTIL_LIST)) {
ClassElement elementType = getRequiredTypeArgument(typeArguments, "E", fieldName, fieldTypeName);
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_LIST_ATTRIBUTE), classTypeDef,
TypeDef.of(elementType));
} else if (fieldTypeName.equals(JAVA_UTIL_MAP)) {
ClassElement keyType = getRequiredTypeArgument(typeArguments, "K", fieldName, fieldTypeName);
ClassElement valueType = getRequiredTypeArgument(typeArguments, "V", fieldName, fieldTypeName);
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_MAP_ATTRIBUTE), classTypeDef,
TypeDef.of(keyType),
TypeDef.of(valueType));
} else {
typeDef = TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_SINGULAR_ATTRIBUTE), classTypeDef, getProperType(TypeDef.of(fieldType)));
}
return attributeDefBuilder.ofType(typeDef).build();
}
private static ClassElement getRequiredTypeArgument(Map<String, ClassElement> typeArguments,
String argumentName,
String fieldName,
String fieldTypeName) {
ClassElement classElement = typeArguments.get(argumentName);
if (classElement == null) {
throw new IllegalArgumentException("Missing generic type argument '" + argumentName + "' for field '" + fieldName
+ "' of type '" + fieldTypeName + "'. Please declare the generic type, for example: "
+ fieldTypeName + "<...> instead of using a raw type.");
}
return classElement;
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

Comment on lines +16 to +18
datasources.default.password=${JDBC_PASSWORD:""}
datasources.default.url=${JDBC_URL:`jdbc:h2:~/devDb:default;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE`}
datasources.default.username=${JDBC_USER:sa}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The placeholder defaults here use quotes/backticks, which are treated as literal characters in a .properties file. This likely sets password to """" and makes the JDBC URL contain backticks, breaking datasource initialization. Use unquoted defaults (e.g. ${JDBC_PASSWORD:} and ${JDBC_URL:jdbc:h2:...}).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3


expect:

constantProps.keySet().stream().anyMatch { o -> trainMetaModelClass.getField(o) != null && trainMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses anyMatch, so the assertion passes if any one constant field matches, even if most expected constants are missing. Use allMatch (and assert the boolean) so the test actually validates every expected constant.

Suggested change
constantProps.keySet().stream().anyMatch { o -> trainMetaModelClass.getField(o) != null && trainMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
constantProps.keySet().stream().allMatch { o -> trainMetaModelClass.getField(o) != null && trainMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

Comment on lines +187 to +189
expect:
constantProps.keySet().stream().anyMatch { o -> parentMetaModelClass.getField(o) != null && parentMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
constantProps.keySet().stream().anyMatch { o -> childMetaModelClass.getField(o) != null && childMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue: anyMatch makes the test pass even if some expected constants are missing. Use allMatch and assert the result for both parent and child metamodels.

Suggested change
expect:
constantProps.keySet().stream().anyMatch { o -> parentMetaModelClass.getField(o) != null && parentMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
constantProps.keySet().stream().anyMatch { o -> childMetaModelClass.getField(o) != null && childMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
def parentKeys = ["ID", "NAME"] as Set
def childKeys = constantProps.keySet()
expect:
parentKeys.stream().allMatch { o -> parentMetaModelClass.getField(o) != null && parentMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
childKeys.stream().allMatch { o -> childMetaModelClass.getField(o) != null && childMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

FIELD_WITHOUT_ACCESSORS: [attributeType: JAKARTA_METAMODEL_SINGULAR_ATTRIBUTE, fieldtype: String.class.getName(), declaringType: "test.FieldAccessClass"]]
expect:

constantProps.keySet().stream().anyMatch { o -> fieldAccessClassMetaModelClass.getField(o) != null && fieldAccessClassMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This anyMatch check doesn't fully validate generated constants. Switch to allMatch (and assert) so every expected constant is verified.

Suggested change
constantProps.keySet().stream().anyMatch { o -> fieldAccessClassMetaModelClass.getField(o) != null && fieldAccessClassMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
constantProps.keySet().stream().allMatch { o -> fieldAccessClassMetaModelClass.getField(o) != null && fieldAccessClassMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

NAME: [attributeType: JAKARTA_METAMODEL_SINGULAR_ATTRIBUTE, fieldtype: String.class.getName(), declaringType: "test.PropertyAccessClass"]]
expect:

constantProps.keySet().stream().anyMatch { o -> propertyAccessClassMetaModelClass.getField(o) != null && propertyAccessClassMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion uses anyMatch, so it can pass while missing expected constants. Use allMatch (and assert) to validate the full set.

Suggested change
constantProps.keySet().stream().anyMatch { o -> propertyAccessClassMetaModelClass.getField(o) != null && propertyAccessClassMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }
constantProps.keySet().stream().allMatch { o -> propertyAccessClassMetaModelClass.getField(o) != null && propertyAccessClassMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d704ec3

@ZARAYACH ZARAYACH changed the title Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project Feat : Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project Mar 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants