From 8f34f0fbe997d2c89a539075880cf43ccf503bcc Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 3 Sep 2025 12:11:32 +0200 Subject: [PATCH] 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. Original pull request: #3355 Closes #3354 --- .../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 67f423ae6..0f9a0a5c8 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 @@ import org.springframework.util.StringUtils; 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 @@ public interface AotContext extends EnvironmentCapable { 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 2ed9945df..9eed36fb1 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 class RepositoryContributor { // 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 class RepositoryContributor { """.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 class RepositoryContributor { 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 3d3b3ffc6..ff578c394 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.List; 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 @@ import org.springframework.data.repository.core.support.RepositoryComposition; 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 @@ class DummyModuleAotRepositoryContext implements AotRepositoryContext { } @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 efe4a741e..a30a5ba2e 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 java.util.Set; 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 @@ class RepositoryContributorUnitTests { 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 @@ class RepositoryContributorUnitTests { contributor.contribute(testGenerationContext); testGenerationContext.writeGeneratedContent(); - contributor.verifyContributedMethods().isNotEmpty().doesNotContainKey("findByFirstname"); }