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 extends QueryMethod> 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 extends QueryMethod> 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");
}