Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
<version>4.0.0-SNAPSHOT</version>
<version>4.0.x-GH-3354-SNAPSHOT</version>

<name>Spring Data Core</name>
<description>Core Spring concepts underpinning every Spring Data module.</description>
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/org/springframework/data/aot/AotContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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})
* <p>
* 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}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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());
}
Expand Down Expand Up @@ -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
Expand All @@ -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<String> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -61,8 +61,8 @@ public ConfigurableListableBeanFactory getBeanFactory() {
}

@Override
public Environment getEnvironment() {
return null;
public MockEnvironment getEnvironment() {
return environment;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {

Expand Down Expand Up @@ -175,7 +254,6 @@ void doesNotContributeBaseClassMethods() {
contributor.contribute(testGenerationContext);
testGenerationContext.writeGeneratedContent();


contributor.verifyContributedMethods().isNotEmpty().doesNotContainKey("findByFirstname");
}

Expand Down