Feat : Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project#3742
Feat : Generate Jakarta JPA Metamodel API for Micronaut Data using Micronaut Sourcegen project#3742ZARAYACH wants to merge 30 commits intomicronaut-projects:5.0.xfrom
Conversation
dstepanov
left a comment
There was a problem hiding this comment.
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" -> |
There was a problem hiding this comment.
Make those constants and use the original class name COLLECTION = Collection.class.getName()
|
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. |
I think this is fine. |
There was a problem hiding this comment.
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-processormodule implementing aTypeElementVisitorthat generates@StaticMetamodelsources. - 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-javamodule 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 |
| // Logging addition logic | ||
| System.out.println("Salary updated: " + salary); |
There was a problem hiding this comment.
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().
| // Logging addition logic | |
| System.out.println("Salary updated: " + salary); |
| dependsOn("clean") | ||
| source = sourceSets.main.java | ||
| classpath = sourceSets.main.compileClasspath | ||
| destinationDirectory = disabledClassesDir | ||
|
|
There was a problem hiding this comment.
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.
| 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) |
| 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); |
There was a problem hiding this comment.
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).
| */ | ||
| 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(); |
There was a problem hiding this comment.
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.
| .ofType(TypeDef.parameterized(ClassTypeDef.of(JAKARTA_METAMODEL_ENTITY_TYPE), elementType)).build(); | |
| .ofType(TypeDef.parameterized(ClassTypeDef.of("jakarta.persistence.metamodel.ManagedType"), elementType)).build(); |
| 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(); | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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; | |
| } |
| 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} |
There was a problem hiding this comment.
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:...}).
|
|
||
| expect: | ||
|
|
||
| constantProps.keySet().stream().anyMatch { o -> trainMetaModelClass.getField(o) != null && trainMetaModelClass.getProperties().get(o) == NameUtils.camelCase(o.toLowerCase()) } |
There was a problem hiding this comment.
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.
| 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()) } |
| 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()) } |
There was a problem hiding this comment.
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.
| 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()) } |
| 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()) } |
There was a problem hiding this comment.
This anyMatch check doesn't fully validate generated constants. Switch to allMatch (and assert) so every expected constant is verified.
| 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()) } |
| 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()) } |
There was a problem hiding this comment.
This assertion uses anyMatch, so it can pass while missing expected constants. Use allMatch (and assert) to validate the full set.
| 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()) } |
…rta tck hibernate submodule
…ck jdbc submodule
…mple submodule & some code cleanup
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_ :
@dstepanov please can you review this pr, and give me your feedback, Thank you.