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. 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..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 @@ -15,14 +15,17 @@ */ 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 java.util.List; 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; @@ -36,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. @@ -47,18 +51,21 @@ 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; private final AotRepositoryCreator creator; private @Nullable TypeReference contributedTypeName; /** * 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) { + this.repositoryContext = repositoryContext; creator = AotRepositoryCreator.forRepository(repositoryContext.getRepositoryInformation(), repositoryContext.getModuleName(), createProjectionFactory()); } @@ -139,32 +146,43 @@ 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); - } - if (logger.isTraceEnabled()) { + String repositoryJson = repositoryContext.isGeneratedRepositoriesMetadataEnabled() + ? generateJsonMetadata(aotBundle) + : null; - logger.trace(""" - ------ AOT Repository.json: %s ------ - %s - ------------------- - """.formatted(aotBundle.repositoryJsonFileName(), repositoryJson)); + if (logger.isTraceEnabled()) { 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))); + } } - generationContext.getGeneratedFiles().addResourceFile(aotBundle.repositoryJsonFileName(), repositoryJson); + if (StringUtils.hasText(repositoryJson)) { + 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 +194,73 @@ 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 = ""; + + 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"); }