From baf259f22cc11bdbb95586514adc2296427778bf Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 2 Sep 2025 14:51:51 +0200 Subject: [PATCH 1/3] Prepare issue branch. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 13143c9f6f..24b1f87fa7 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 4.0.0-SNAPSHOT + 4.0.x-GH-3354-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. From 33c0c84bc58c6d4a9f2caab22aa9b4ef33b72546 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 3 Sep 2025 12:11:32 +0200 Subject: [PATCH 2/3] Avoid attempts to override AOT generated query method metadata. Prior to this change regenerating repository instances for eg. test execution caused trouble when trying to override existing json metadata files. We now back off in case of existing files and added an explicit config flag for users to opt out of having the metadata file being present in the target resources. --- .../springframework/data/aot/AotContext.java | 14 +++ .../aot/generate/RepositoryContributor.java | 49 ++++++++--- .../DummyModuleAotRepositoryContext.java | 8 +- .../RepositoryContributorUnitTests.java | 86 ++++++++++++++++++- 4 files changed, 136 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/springframework/data/aot/AotContext.java b/src/main/java/org/springframework/data/aot/AotContext.java index 67f423ae60..0f9a0a5c8d 100644 --- a/src/main/java/org/springframework/data/aot/AotContext.java +++ b/src/main/java/org/springframework/data/aot/AotContext.java @@ -54,6 +54,7 @@ public interface AotContext extends EnvironmentCapable { String GENERATED_REPOSITORIES_ENABLED = "spring.aot.repositories.enabled"; + String GENERATED_REPOSITORIES_JSON_ENABLED = "spring.aot.repositories.metadata.enabled"; /** * Create an {@link AotContext} backed by the given {@link BeanFactory}. @@ -116,6 +117,19 @@ default boolean isGeneratedRepositoriesEnabled(@Nullable String moduleName) { return environment.getProperty(modulePropertyName, Boolean.class, true); } + /** + * Checks if repository metadata file writing is enabled by checking environment variables for general + * enablement ({@link #GENERATED_REPOSITORIES_JSON_ENABLED}) + *

+ * Unset properties are considered being {@literal true}. + * + * @return indicator if repository metadata should be written + * @since 5.0 + */ + default boolean isGeneratedRepositoriesMetadataEnabled() { + return getEnvironment().getProperty(GENERATED_REPOSITORIES_JSON_ENABLED, Boolean.class, true); + } + /** * Returns a reference to the {@link ConfigurableListableBeanFactory} backing this {@link AotContext}. * diff --git a/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java b/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java index 2ed9945df1..9eed36fb1e 100644 --- a/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java +++ b/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java @@ -15,14 +15,16 @@ */ package org.springframework.data.repository.aot.generate; +import java.io.ByteArrayInputStream; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.util.Collections; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; - import org.springframework.aot.generate.GeneratedClass; +import org.springframework.aot.generate.GeneratedFiles.Kind; import org.springframework.aot.generate.GeneratedTypeReference; import org.springframework.aot.generate.GenerationContext; import org.springframework.aot.hint.MemberCategory; @@ -49,6 +51,7 @@ public class RepositoryContributor { private static final Log logger = LogFactory.getLog(RepositoryContributor.class); private static final String FEATURE_NAME = "AotRepository"; + private final AotRepositoryContext repositoryContext; private final AotRepositoryCreator creator; private @Nullable TypeReference contributedTypeName; @@ -59,6 +62,7 @@ public class RepositoryContributor { */ public RepositoryContributor(AotRepositoryContext repositoryContext) { + this.repositoryContext = repositoryContext; creator = AotRepositoryCreator.forRepository(repositoryContext.getRepositoryInformation(), repositoryContext.getModuleName(), createProjectionFactory()); } @@ -139,20 +143,18 @@ public final void contribute(GenerationContext generationContext) { // write out the content AotBundle aotBundle = creator.create(targetTypeSpec); - String repositoryJson; - try { - repositoryJson = aotBundle.metadata().get().toJson().toString(2); - } catch (JSONException e) { - throw new RuntimeException(e); - } + String repositoryJson = generateJsonMetadata(aotBundle); + if (logger.isTraceEnabled()) { - logger.trace(""" - ------ AOT Repository.json: %s ------ - %s - ------------------- - """.formatted(aotBundle.repositoryJsonFileName(), repositoryJson)); + if (repositoryContext.isGeneratedRepositoriesMetadataEnabled()) { + logger.trace(""" + ------ AOT Repository.json: %s ------ + %s + ------------------- + """.formatted(aotBundle.repositoryJsonFileName(), repositoryJson)); + } TypeSpec typeSpec = targetTypeSpec.build(); JavaFile javaFile = JavaFile.builder(creator.packageName(), typeSpec).build(); @@ -164,7 +166,14 @@ public final void contribute(GenerationContext generationContext) { """.formatted(typeSpec.name(), javaFile)); } - generationContext.getGeneratedFiles().addResourceFile(aotBundle.repositoryJsonFileName(), repositoryJson); + if (repositoryContext.isGeneratedRepositoriesMetadataEnabled()) { + generationContext.getGeneratedFiles().handleFile(Kind.RESOURCE, aotBundle.repositoryJsonFileName(), + fileHandler -> { + if (!fileHandler.exists()) { + fileHandler.create(() -> new ByteArrayInputStream(repositoryJson.getBytes(StandardCharsets.UTF_8))); + } + }); + } }); // generate native runtime hints @@ -176,6 +185,20 @@ public final void contribute(GenerationContext generationContext) { MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); } + private String generateJsonMetadata(AotBundle aotBundle) { + + String repositoryJson = ""; + + if (repositoryContext.isGeneratedRepositoriesMetadataEnabled()) { + try { + repositoryJson = aotBundle.metadata().get().toJson().toString(2); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + return repositoryJson; + } + /** * Customization hook for store implementations to customize class after building the entire class. */ diff --git a/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java b/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java index 3d3b3ffc64..ff578c3943 100644 --- a/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java +++ b/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java @@ -21,16 +21,15 @@ import java.util.Set; import org.jspecify.annotations.Nullable; - import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.core.annotation.MergedAnnotation; -import org.springframework.core.env.Environment; import org.springframework.core.io.ClassPathResource; import org.springframework.core.test.tools.ClassFile; import org.springframework.data.repository.config.AotRepositoryContext; import org.springframework.data.repository.config.RepositoryConfigurationSource; import org.springframework.data.repository.core.RepositoryInformation; import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.mock.env.MockEnvironment; /** * Dummy {@link AotRepositoryContext} used to simulate module specific repository implementation. @@ -40,6 +39,7 @@ class DummyModuleAotRepositoryContext implements AotRepositoryContext { private final StubRepositoryInformation repositoryInformation; + private final MockEnvironment environment = new MockEnvironment(); public DummyModuleAotRepositoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition) { this.repositoryInformation = new StubRepositoryInformation(repositoryInterface, composition); @@ -61,8 +61,8 @@ public ConfigurableListableBeanFactory getBeanFactory() { } @Override - public Environment getEnvironment() { - return null; + public MockEnvironment getEnvironment() { + return environment; } @Override diff --git a/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java b/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java index efe4a741e4..a30a5ba2e4 100644 --- a/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java +++ b/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java @@ -15,9 +15,11 @@ */ package org.springframework.data.repository.aot.generate; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import example.UserRepository; import example.UserRepositoryExtension; @@ -33,6 +35,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.core.test.tools.ResourceFile; import org.springframework.core.test.tools.TestCompiler; import org.springframework.data.aot.CodeContributionAssert; import org.springframework.data.repository.CrudRepository; @@ -137,6 +140,82 @@ void writesCapturedQueryMetadataToResources() { new CodeContributionAssert(generationContext).contributesReflectionFor(expectedTypeName); } + @Test // GH-3354 + void doesNotWriteCapturedQueryMetadataToResourcesIfDisabled() { + + DummyModuleAotRepositoryContext aotContext = new DummyModuleAotRepositoryContext(UserRepository.class, null); + aotContext.getEnvironment().setProperty("spring.aot.repositories.metadata.enabled", "false"); + + RepositoryContributor repositoryContributor = new RepositoryContributor(aotContext) { + + @Override + protected @Nullable MethodContributor contributeQueryMethod(Method method) { + + return MethodContributor + .forQueryMethod( + new QueryMethod(method, getRepositoryInformation(), getProjectionFactory(), DefaultParameters::new)) + .withMetadata(() -> Map.of("filter", "FILTER(%s > $1)".formatted(method.getName()), "project", + Arrays.stream(method.getParameters()).map(Parameter::getName).toList())) + .contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + if (!ClassUtils.isVoidType(method.getReturnType())) { + builder.addStatement("return null"); + } + + return builder.build(); + }); + } + }; + + TestGenerationContext generationContext = new TestGenerationContext(UserRepository.class); + repositoryContributor.contribute(generationContext); + generationContext.writeGeneratedContent(); + + TestCompiler.forSystem().with(generationContext).compile(compiled -> { + assertThat(compiled.getResourceFiles()).isEmpty(); + }); + } + + @Test // GH-3354 + void doesNotWriteCapturedQueryMetadataToResourcesIfAlreadyExists() { + + DummyModuleAotRepositoryContext aotContext = new DummyModuleAotRepositoryContext(UserRepository.class, null); + + RepositoryContributor repositoryContributor = new RepositoryContributor(aotContext) { + + @Override + protected @Nullable MethodContributor contributeQueryMethod(Method method) { + + return MethodContributor + .forQueryMethod( + new QueryMethod(method, getRepositoryInformation(), getProjectionFactory(), DefaultParameters::new)) + .withMetadata(() -> Map.of("filter", "FILTER(%s > $1)".formatted(method.getName()), "project", + Arrays.stream(method.getParameters()).map(Parameter::getName).toList())) + .contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + if (!ClassUtils.isVoidType(method.getReturnType())) { + builder.addStatement("return null"); + } + + return builder.build(); + }); + } + }; + + TestGenerationContext generationContext = new TestGenerationContext(UserRepository.class); + repositoryContributor.contribute(generationContext); + generationContext.writeGeneratedContent(); + + ResourceFile rf = ResourceFile.of(UserRepository.class.getName().replace('.', '/') + ".json", + "But you're untouchable, burning brighter than the sun"); + TestCompiler.forSystem().with(generationContext).withResources(rf).compile(compiled -> { + String content = compiled.getResourceFile().getContent(); + assertThat(content).contains("you're untouchable").doesNotContain("FILTER(doSomething > $1)"); + }); + } + @Test // GH-3279 void callsMethodContributionForQueryMethod() { @@ -175,7 +254,6 @@ void doesNotContributeBaseClassMethods() { contributor.contribute(testGenerationContext); testGenerationContext.writeGeneratedContent(); - contributor.verifyContributedMethods().isNotEmpty().doesNotContainKey("findByFirstname"); } From 233489b2bca82e7171b6c1590f29d96ffbaf3b47 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 4 Sep 2025 10:41:44 +0200 Subject: [PATCH 3/3] Polishing. Add renderers to format trace messages. --- .../aot/generate/RepositoryContributor.java | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java b/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java index 9eed36fb1e..aad8c144d9 100644 --- a/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java +++ b/src/main/java/org/springframework/data/repository/aot/generate/RepositoryContributor.java @@ -19,6 +19,7 @@ import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.List; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -38,6 +39,7 @@ import org.springframework.data.repository.query.QueryMethod; import org.springframework.javapoet.JavaFile; import org.springframework.javapoet.TypeSpec; +import org.springframework.util.StringUtils; /** * Contributor for AOT repository fragments. @@ -49,6 +51,7 @@ public class RepositoryContributor { private static final Log logger = LogFactory.getLog(RepositoryContributor.class); + private static final Log jsonLogger = LogFactory.getLog(RepositoryContributor.class.getName() + ".json"); private static final String FEATURE_NAME = "AotRepository"; private final AotRepositoryContext repositoryContext; @@ -58,7 +61,7 @@ public class RepositoryContributor { /** * Create a new {@code RepositoryContributor} for the given {@link AotRepositoryContext}. * - * @param repositoryContext + * @param repositoryContext context providing details about the repository to be generated. */ public RepositoryContributor(AotRepositoryContext repositoryContext) { @@ -144,29 +147,35 @@ public final void contribute(GenerationContext generationContext) { // write out the content AotBundle aotBundle = creator.create(targetTypeSpec); - String repositoryJson = generateJsonMetadata(aotBundle); + String repositoryJson = repositoryContext.isGeneratedRepositoriesMetadataEnabled() + ? generateJsonMetadata(aotBundle) + : null; if (logger.isTraceEnabled()) { - if (repositoryContext.isGeneratedRepositoriesMetadataEnabled()) { - logger.trace(""" - ------ AOT Repository.json: %s ------ - %s - ------------------- - """.formatted(aotBundle.repositoryJsonFileName(), repositoryJson)); - } - TypeSpec typeSpec = targetTypeSpec.build(); JavaFile javaFile = JavaFile.builder(creator.packageName(), typeSpec).build(); logger.trace(""" - ------ AOT Generated Repository: %s ------ + %s - ------------------- - """.formatted(typeSpec.name(), javaFile)); + """.formatted(formatTraceMessage("Generated Repository", typeSpec.name(), + prefixWithLineNumbers(javaFile.toString()).trim()))); + } + + if (jsonLogger.isTraceEnabled()) { + + if (StringUtils.hasText(repositoryJson)) { + + jsonLogger.trace(""" + + %s + """.formatted( + formatTraceMessage("Repository.json", aotBundle.repositoryJsonFileName(), repositoryJson))); + } } - if (repositoryContext.isGeneratedRepositoriesMetadataEnabled()) { + if (StringUtils.hasText(repositoryJson)) { generationContext.getGeneratedFiles().handleFile(Kind.RESOURCE, aotBundle.repositoryJsonFileName(), fileHandler -> { if (!fileHandler.exists()) { @@ -185,6 +194,59 @@ public final void contribute(GenerationContext generationContext) { MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS); } + /** + * Format a trace message with a title, label, and content using ascii art style borders. + * + * @param title title of the block (e.g. "Generated Source"). + * @param label label that follows the title. Will be truncated if too long. + * @param content the actual content to be displayed. + * @return + */ + public static String formatTraceMessage(String title, String label, String content) { + + int remainingLength = 64 - title.length(); + String header = ("= %s: %-" + remainingLength + "." + remainingLength + "s =").formatted(title, + formatMaxLength(label, remainingLength - 1)); + + return """ + ====================================================================== + %s + ====================================================================== + %s + ====================================================================== + """.formatted(header, content); + } + + private static String formatMaxLength(String name, int length) { + return name.length() > length ? "…" + name.substring(name.length() - length) : name; + } + + /** + * Format the given contents by prefixing each line with its line number in a block comment. + * + * @param contents + * @return + */ + public static String prefixWithLineNumbers(String contents) { + + List lines = contents.lines().toList(); + + int decimals = (int) Math.log10(Math.abs(lines.size())) + 1; + StringBuilder builder = new StringBuilder(); + + int lineNumber = 1; + for (String s : lines) { + + String formattedLineNumber = String.format("/* %-" + decimals + "d */\t", lineNumber); + + builder.append(formattedLineNumber).append(s).append(System.lineSeparator()); + + lineNumber++; + } + + return builder.toString(); + } + private String generateJsonMetadata(AotBundle aotBundle) { String repositoryJson = "";