Add support for AOT generated resources

This commit updates the AOT infrastructure to handle generated resources
in a similar fashion than generated classes: naming conventions, feature
prefixes, and uniqueness are applied.

The new abstraction also provides a more explicit contract that guides
users to either create the resource or create it if it does not exist
and validate its content if it does.

As part of this change ClassNameGenerator has been renamed to
NameGenerator as it is responsible to generate names for both classes
and resources.

Closes gh-35862
This commit is contained in:
Stéphane Nicoll
2026-09-17 18:22:26 +02:00
committed by Brian Clozel
parent 7ca362a003
commit 496ed729a0
17 changed files with 1122 additions and 184 deletions
@@ -27,10 +27,10 @@ import javax.lang.model.element.Modifier;
import org.junit.jupiter.api.Test;
import org.springframework.aot.generate.ClassNameGenerator;
import org.springframework.aot.generate.GenerationContext;
import org.springframework.aot.generate.MethodReference;
import org.springframework.aot.generate.MethodReference.ArgumentCodeGenerator;
import org.springframework.aot.generate.NameGenerator;
import org.springframework.aot.generate.ValueCodeGenerationException;
import org.springframework.aot.test.generate.TestGenerationContext;
import org.springframework.beans.factory.aot.BeanRegistrationsAotContribution.Registration;
@@ -109,7 +109,7 @@ class BeanRegistrationsAotContributionTests {
@Test
void applyToWhenHasNameGeneratesPrefixedFeatureName() {
this.generationContext = new TestGenerationContext(
new ClassNameGenerator(TestGenerationContext.TEST_TARGET, "Management"));
new NameGenerator(TestGenerationContext.TEST_TARGET, "Management"));
this.beanFactoryInitializationCode = new MockBeanFactoryInitializationCode(this.generationContext);
RegisteredBean registeredBean = registerBean(new RootBeanDefinition(TestBean.class));
BeanDefinitionMethodGenerator generator = new BeanDefinitionMethodGenerator(this.methodGeneratorFactory,
@@ -23,9 +23,9 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.aot.generate.ClassNameGenerator;
import org.springframework.aot.generate.DefaultGenerationContext;
import org.springframework.aot.generate.FileSystemGeneratedFiles;
import org.springframework.aot.generate.NameGenerator;
import org.springframework.aot.hint.ExecutableMode;
import org.springframework.aot.hint.ReflectionHints;
import org.springframework.aot.hint.TypeReference;
@@ -102,7 +102,7 @@ public abstract class ContextAotProcessor extends AbstractAotProcessor<ClassName
protected ClassName performAotProcessing(GenericApplicationContext applicationContext) {
FileSystemGeneratedFiles generatedFiles = createFileSystemGeneratedFiles();
DefaultGenerationContext generationContext = new DefaultGenerationContext(
createClassNameGenerator(), generatedFiles);
createNameGenerator(), generatedFiles);
ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator();
ClassName generatedInitializerClassName = generator.processAheadOfTime(applicationContext, generationContext);
registerEntryPointHint(generationContext, generatedInitializerClassName);
@@ -113,14 +113,15 @@ public abstract class ContextAotProcessor extends AbstractAotProcessor<ClassName
}
/**
* Callback to customize the {@link ClassNameGenerator}.
* <p>By default, a standard {@link ClassNameGenerator} using the configured
* Callback to customize the {@link NameGenerator}.
* <p>By default, a standard {@link NameGenerator} using the configured
* {@linkplain #getApplicationClass() application entry point} as the default
* target is used.
* @return the class name generator
* @return the name generator
* @since 7.1
*/
protected ClassNameGenerator createClassNameGenerator() {
return new ClassNameGenerator(ClassName.get(getApplicationClass()));
protected NameGenerator createNameGenerator() {
return new NameGenerator(ClassName.get(getApplicationClass()));
}
/**
@@ -18,10 +18,10 @@ package org.springframework.aot.test.generate;
import java.util.function.UnaryOperator;
import org.springframework.aot.generate.ClassNameGenerator;
import org.springframework.aot.generate.DefaultGenerationContext;
import org.springframework.aot.generate.GenerationContext;
import org.springframework.aot.generate.InMemoryGeneratedFiles;
import org.springframework.aot.generate.NameGenerator;
import org.springframework.core.test.tools.TestCompiler;
import org.springframework.javapoet.ClassName;
@@ -42,11 +42,11 @@ public class TestGenerationContext extends DefaultGenerationContext implements U
public static final ClassName TEST_TARGET = ClassName.get("com.example", "TestTarget");
/**
* Create an instance using the specified {@link ClassNameGenerator}.
* @param classNameGenerator the class name generator to use
* Create an instance using the specified {@link NameGenerator}.
* @param nameGenerator the name generator to use for classes and resources
*/
public TestGenerationContext(ClassNameGenerator classNameGenerator) {
super(classNameGenerator, new InMemoryGeneratedFiles());
public TestGenerationContext(NameGenerator nameGenerator) {
super(nameGenerator, new InMemoryGeneratedFiles());
}
/**
@@ -54,7 +54,7 @@ public class TestGenerationContext extends DefaultGenerationContext implements U
* @param target the default target class name to use
*/
public TestGenerationContext(ClassName target) {
this(new ClassNameGenerator(target));
this(new NameGenerator(target));
}
/**
@@ -41,6 +41,8 @@ public class DefaultGenerationContext implements GenerationContext {
private final GeneratedClasses generatedClasses;
private final GeneratedResources generatedResources;
private final GeneratedFiles generatedFiles;
private final RuntimeHints runtimeHints;
@@ -48,27 +50,28 @@ public class DefaultGenerationContext implements GenerationContext {
/**
* Create a new {@link DefaultGenerationContext} instance backed by the
* specified {@link ClassNameGenerator} and {@link GeneratedFiles}.
* @param classNameGenerator the naming convention to use for generated
* class names
* specified {@link NameGenerator} and {@link GeneratedFiles}.
* @param nameGenerator the naming convention to use for generated
* classes and resources
* @param generatedFiles the generated files
*/
public DefaultGenerationContext(ClassNameGenerator classNameGenerator, GeneratedFiles generatedFiles) {
this(classNameGenerator, generatedFiles, new RuntimeHints());
public DefaultGenerationContext(NameGenerator nameGenerator, GeneratedFiles generatedFiles) {
this(nameGenerator, generatedFiles, new RuntimeHints());
}
/**
* Create a new {@link DefaultGenerationContext} instance backed by the
* specified {@link ClassNameGenerator}, {@link GeneratedFiles}, and
* specified {@link NameGenerator}, {@link GeneratedFiles}, and
* {@link RuntimeHints}.
* @param classNameGenerator the naming convention to use for generated
* class names
* @param nameGenerator the naming convention to use for generated
* classes and resources
* @param generatedFiles the generated files
* @param runtimeHints the runtime hints
*/
public DefaultGenerationContext(ClassNameGenerator classNameGenerator, GeneratedFiles generatedFiles,
public DefaultGenerationContext(NameGenerator nameGenerator, GeneratedFiles generatedFiles,
RuntimeHints runtimeHints) {
this(new GeneratedClasses(classNameGenerator), generatedFiles, runtimeHints);
this(new GeneratedClasses(nameGenerator), new GeneratedResources(nameGenerator),
generatedFiles, runtimeHints);
}
/**
@@ -78,14 +81,16 @@ public class DefaultGenerationContext implements GenerationContext {
* @param generatedFiles the generated files
* @param runtimeHints the runtime hints
*/
DefaultGenerationContext(GeneratedClasses generatedClasses,
DefaultGenerationContext(GeneratedClasses generatedClasses, GeneratedResources generatedResources,
GeneratedFiles generatedFiles, RuntimeHints runtimeHints) {
Assert.notNull(generatedClasses, "'generatedClasses' must not be null");
Assert.notNull(generatedResources, "'generatedResources' must not be null");
Assert.notNull(generatedFiles, "'generatedFiles' must not be null");
Assert.notNull(runtimeHints, "'runtimeHints' must not be null");
this.sequenceGenerator = new ConcurrentHashMap<>();
this.generatedClasses = generatedClasses;
this.generatedResources = generatedResources;
this.generatedFiles = generatedFiles;
this.runtimeHints = runtimeHints;
}
@@ -104,6 +109,7 @@ public class DefaultGenerationContext implements GenerationContext {
}
this.sequenceGenerator = existing.sequenceGenerator;
this.generatedClasses = existing.generatedClasses.withFeatureNamePrefix(featureName);
this.generatedResources = existing.generatedResources.withFeatureNamePrefix(featureName);
this.generatedFiles = existing.generatedFiles;
this.runtimeHints = existing.runtimeHints;
}
@@ -114,6 +120,11 @@ public class DefaultGenerationContext implements GenerationContext {
return this.generatedClasses;
}
@Override
public GeneratedResources getGeneratedResources() {
return this.generatedResources;
}
@Override
public GeneratedFiles getGeneratedFiles() {
return this.generatedFiles;
@@ -134,6 +145,7 @@ public class DefaultGenerationContext implements GenerationContext {
*/
public void writeGeneratedContent() {
this.generatedClasses.writeTo(this.generatedFiles);
this.generatedResources.writeTo(this.generatedFiles);
}
}
@@ -42,7 +42,7 @@ import org.springframework.util.Assert;
*/
public class GeneratedClasses {
private final ClassNameGenerator classNameGenerator;
private final NameGenerator nameGenerator;
private final List<GeneratedClass> classes;
@@ -51,16 +51,16 @@ public class GeneratedClasses {
/**
* Create a new instance using the specified naming conventions.
* @param classNameGenerator the class name generator to use
* @param nameGenerator the name generator to use
*/
GeneratedClasses(ClassNameGenerator classNameGenerator) {
this(classNameGenerator, new ArrayList<>(), new ConcurrentHashMap<>());
GeneratedClasses(NameGenerator nameGenerator) {
this(nameGenerator, new ArrayList<>(), new ConcurrentHashMap<>());
}
private GeneratedClasses(ClassNameGenerator classNameGenerator,
private GeneratedClasses(NameGenerator nameGenerator,
List<GeneratedClass> classes, Map<Owner, GeneratedClass> classesByOwner) {
Assert.notNull(classNameGenerator, "'classNameGenerator' must not be null");
this.classNameGenerator = classNameGenerator;
Assert.notNull(nameGenerator, "'nameGenerator' must not be null");
this.nameGenerator = nameGenerator;
this.classes = classes;
this.classesByOwner = classesByOwner;
}
@@ -81,7 +81,7 @@ public class GeneratedClasses {
Assert.hasLength(featureName, "'featureName' must not be empty");
Assert.notNull(type, "'type' must not be null");
Owner owner = new Owner(this.classNameGenerator.getFeatureNamePrefix(), featureName, null);
Owner owner = new Owner(this.nameGenerator.getFeatureNamePrefix(), featureName, null);
GeneratedClass generatedClass = this.classesByOwner.computeIfAbsent(owner, key -> createAndAddGeneratedClass(featureName, null, type));
generatedClass.assertSameType(type);
return generatedClass;
@@ -105,7 +105,7 @@ public class GeneratedClasses {
Assert.hasLength(featureName, "'featureName' must not be empty");
Assert.notNull(targetComponent, "'targetComponent' must not be null");
Assert.notNull(type, "'type' must not be null");
Owner owner = new Owner(this.classNameGenerator.getFeatureNamePrefix(), featureName, targetComponent);
Owner owner = new Owner(this.nameGenerator.getFeatureNamePrefix(), featureName, targetComponent);
GeneratedClass generatedClass = this.classesByOwner.computeIfAbsent(owner, key ->
createAndAddGeneratedClass(featureName, targetComponent, type));
generatedClass.assertSameType(type);
@@ -180,7 +180,7 @@ public class GeneratedClasses {
private GeneratedClass createAndAddGeneratedClass(String featureName,
@Nullable ClassName targetComponent, Consumer<TypeSpec.Builder> type) {
ClassName className = this.classNameGenerator.generateClassName(featureName, targetComponent);
ClassName className = this.nameGenerator.generateClassName(featureName, targetComponent);
GeneratedClass generatedClass = new GeneratedClass(className, type);
this.classes.add(generatedClass);
return generatedClass;
@@ -208,7 +208,7 @@ public class GeneratedClasses {
* @return a new instance for the specified feature name prefix
*/
GeneratedClasses withFeatureNamePrefix(String featureNamePrefix) {
return new GeneratedClasses(this.classNameGenerator.withFeatureNamePrefix(featureNamePrefix),
return new GeneratedClasses(this.nameGenerator.withFeatureNamePrefix(featureNamePrefix),
this.classes, this.classesByOwner);
}
@@ -0,0 +1,255 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aot.generate;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.StringJoiner;
import java.util.function.Consumer;
import org.jspecify.annotations.Nullable;
import org.springframework.aot.generate.GeneratedFiles.Kind;
import org.springframework.core.io.InputStreamSource;
import org.springframework.util.Assert;
import org.springframework.util.function.ThrowingConsumer;
/**
* A single generated resource.
*
* @author Stephane Nicoll
* @since 7.1
* @see GeneratedResources
*/
public class GeneratedResource {
private final String path;
private final Content content;
/**
* Create a new, empty instance with the given {@code path}.
* @param path the absolute path of this resource on the classpath
*/
GeneratedResource(String path) {
this.path = path;
this.content = new DefaultContent();
}
/**
* Return the absolute path of this resource on the classpath.
* <p>The path returned by this method does not have a leading slash and is
* suitable for generated code that use {@link ClassLoader#getResource(String)}.
* @return the path of this resource on the classpath
* @see #hasContent()
*/
public String getPath() {
return this.path;
}
/**
* Whether this instance has content associated to it. Instances that do
* not have content are not contributed to {@link GeneratedFiles}.
* @return {@code true} if this instance has content
* @see #handle(Consumer)
*/
public boolean hasContent() {
return this.content.exists();
}
/**
* Handle this instance, using the provided {@link Content}.
* <p>Generated resources are usually written only once, but there are cases
* where they need to be stored in a unique path and several rounds of
* AOT processing may touch the same file. For cases like this use
* {@code createOrValidate} as only one attempt to create the content is
* allowed.
* @param content the content to use
*/
public void handle(Consumer<Content> content) {
content.accept(this.content);
}
void writeTo(GeneratedFiles generatedFiles) {
if (hasContent()) {
generatedFiles.addFile(Kind.RESOURCE, this.path, this.content);
}
}
@Override
public String toString() {
return new StringJoiner(", ", GeneratedResource.class.getSimpleName() + "[", "]")
.add("path='" + this.path + "'")
.toString();
}
/**
* Abstraction of the content of a generated resource.
*/
public interface Content extends InputStreamSource {
/**
* Specify if this content exists. Use this as a check before calling
* {@link #getInputStream()}.
* @return {@code true} if this instance has been created
*/
boolean exists();
/**
* Create this instance with the content from the given {@link CharSequence}.
* @param content the content
* @throws IllegalStateException if this instance has already been created
*/
void create(CharSequence content);
/**
* Create this instance with the content from the given {@link CharSequence}
* if it does not exist or validate that the existing content matches.
* @param content the content
* @throws IllegalArgumentException if this instance has already been created,
* but its content does not match the given {@code content}
*/
void createOrValidate(CharSequence content);
/**
* Create this instance with the content written to an {@link Appendable}
* passed to the given {@link ThrowingConsumer}.
* @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the content
* @throws IllegalStateException if this instance has already been created
*/
void create(ThrowingConsumer<Appendable> content);
/**
* Create this instance with the content written to an {@link Appendable}
* passed to the given {@link ThrowingConsumer} if it does not exist or
* validate that the existing content matches.
* @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the content
* @throws IllegalArgumentException if this instance has already been created,
* but its content does not match the given {@code content}
*/
void createOrValidate(ThrowingConsumer<Appendable> content);
/**
* Create this instance with the content from the given {@link InputStreamSource}.
* @param content an {@link InputStreamSource} that will provide an input
* stream containing the content
* @throws IllegalStateException if this instance has already been created
*/
void create(InputStreamSource content);
/**
* Create this instance with the content from the given {@link InputStreamSource}
* if it does not exist or validate that the existing content matches.
* @param content an {@link InputStreamSource} that will provide an input
* stream containing the content
* @throws IllegalArgumentException if this instance has already been created,
* but its content does not match the given {@code content}
*/
void createOrValidate(InputStreamSource content);
}
private final class DefaultContent implements Content {
private @Nullable InputStreamSource source;
@Override
public InputStream getInputStream() throws IOException {
Assert.state(this.source != null, "No content is set for " + GeneratedResource.this);
return this.source.getInputStream();
}
@Override
public boolean exists() {
return (this.source != null);
}
@Override
public void create(CharSequence content) {
create(appendable -> appendable.append(content));
}
@Override
public void createOrValidate(CharSequence content) {
createOrValidate(appendable -> appendable.append(content));
}
@Override
public void create(ThrowingConsumer<Appendable> content) {
create(new AppendableConsumerInputStreamSource(content));
}
@Override
public void createOrValidate(ThrowingConsumer<Appendable> content) {
createOrValidate(new AppendableConsumerInputStreamSource(content));
}
@Override
public void create(InputStreamSource content) {
Assert.notNull(content, "'content' must not be null");
if (exists()) {
throw new IllegalStateException(
"Content for generated resource at '%s' has already been set".formatted(getPath()));
}
this.source = content;
}
@Override
public void createOrValidate(InputStreamSource content) {
Assert.notNull(content, "'content' must not be null");
if (this.source == null) {
this.source = content;
}
if (!hasSomeContentAs(content)) {
throw new IllegalArgumentException(
"Content for generated resource at '%s' differs from the content that has already been written"
.formatted(getPath()));
}
}
private boolean hasSomeContentAs(InputStreamSource content) {
try (InputStream input1 = getInputStream(); InputStream input2 = content.getInputStream()) {
if (input1 == input2) {
return true;
}
byte[] buffer1 = new byte[8192];
byte[] buffer2 = new byte[8192];
int count1;
int count2;
while ((count1 = input1.read(buffer1)) != -1) {
count2 = input2.read(buffer2);
if (count1 != count2) {
return false;
}
if (!Arrays.equals(buffer1, 0, count1, buffer2, 0, count2)) {
return false;
}
}
return input2.read() == -1;
}
catch (IOException ex) {
throw new RuntimeException("Failed to validate content for generated resource at '%s'"
.formatted(getPath()), ex);
}
}
}
}
@@ -0,0 +1,163 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aot.generate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jspecify.annotations.Nullable;
import org.springframework.javapoet.ClassName;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* A managed collection of generated resources.
*
* <p>This class is stateful, so the same instance should be used for all resource
* generation.
*
* @author Stephane Nicoll
* @since 7.1
* @see GeneratedResource
*/
public class GeneratedResources {
private final NameGenerator nameGenerator;
private final List<GeneratedResource> resources;
private final Map<String, GeneratedResource> resourcesByPath;
private final Map<Owner, GeneratedResource> resourcesByOwner;
GeneratedResources(NameGenerator nameGenerator) {
this(nameGenerator, new ArrayList<>(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>());
}
private GeneratedResources(NameGenerator nameGenerator, List<GeneratedResource> resources,
Map<String, GeneratedResource> resourcesByPath, Map<Owner, GeneratedResource> resourcesByOwner) {
this.nameGenerator = nameGenerator;
this.resources = resources;
this.resourcesByPath = resourcesByPath;
this.resourcesByOwner = resourcesByOwner;
}
/**
* Return the {@link GeneratedResource} at the given {@code path}.
* <p>A valid {@code path} is an absolute location on the classpath with
* directories separated with {@code /}, and without a leading slash.
* For instance {@code com/example/my-resource.txt}.
* @param path the absolute path of the resource on the classpath
* @return a generated resource
*/
public GeneratedResource getOrAdd(String path) {
Assert.hasLength(path, "'path' must not be empty");
if (!path.equals(cleanedPath(path))) {
throw new IllegalArgumentException("Invalid classpath location '%s'".formatted(path));
}
return this.resourcesByPath.computeIfAbsent(path, key -> {
GeneratedResource resource = new GeneratedResource(path);
this.resources.add(resource);
return resource;
});
}
private static String cleanedPath(String path) {
String cleanedPath = StringUtils.cleanPath(path);
if (cleanedPath.startsWith("/")) {
cleanedPath = cleanedPath.substring(1);
}
return cleanedPath;
}
public GeneratedResource getOrAddForFeature(String extension, String featureName) {
Assert.hasLength(extension, "'extension' must not be empty");
Assert.hasLength(featureName, "'featureName' must not be empty");
Owner owner = new Owner(extension, this.nameGenerator.getFeatureNamePrefix(), featureName, null);
return this.resourcesByOwner.computeIfAbsent(owner,
key -> createAndAddGeneratedResource(extension, featureName, null));
}
public GeneratedResource getOrAddForFeatureComponent(String extension, String featureName,
ClassName targetComponent) {
Assert.hasLength(extension, "'extension' must not be empty");
Assert.hasLength(featureName, "'featureName' must not be empty");
Assert.notNull(targetComponent, "'targetComponent' must not be null");
Owner owner = new Owner(extension, this.nameGenerator.getFeatureNamePrefix(), featureName, targetComponent);
return this.resourcesByOwner.computeIfAbsent(owner,
key -> createAndAddGeneratedResource(extension, featureName, targetComponent));
}
public GeneratedResource addForFeature(String extension, String featureName) {
Assert.hasLength(extension, "'extension' must not be empty");
Assert.hasLength(featureName, "'featureName' must not be empty");
return createAndAddGeneratedResource(extension, featureName, null);
}
public GeneratedResource addForFeatureComponent(String extension, String featureName,
ClassName targetComponent) {
Assert.hasLength(extension, "'extension' must not be empty");
Assert.hasLength(featureName, "'featureName' must not be empty");
Assert.notNull(targetComponent, "'targetComponent' must not be null");
return createAndAddGeneratedResource(extension, featureName, targetComponent);
}
private GeneratedResource createAndAddGeneratedResource(String extension, String featureName,
@Nullable ClassName targetComponent) {
String path = this.nameGenerator.generateResourcePath(extension, featureName, targetComponent);
GeneratedResource generatedResource = new GeneratedResource(path);
this.resources.add(generatedResource);
return generatedResource;
}
/**
* Write the {@link GeneratedResource generated resources} using the given
* {@link GeneratedFiles} instance.
* @param generatedFiles where to write the generated resources
*/
void writeTo(GeneratedFiles generatedFiles) {
Assert.notNull(generatedFiles, "'generatedFiles' must not be null");
List<GeneratedResource> generatedResources = new ArrayList<>(this.resources);
generatedResources.sort(Comparator.comparing(GeneratedResource::getPath));
for (GeneratedResource generatedResource : generatedResources) {
generatedResource.writeTo(generatedFiles);
}
}
/**
* Create a new instance using the specified feature name prefix to qualify
* generated paths for a dedicated round of AOT processing.
* @param featureNamePrefix the feature name prefix to use
* @return a new instance for the specified feature name prefix
*/
GeneratedResources withFeatureNamePrefix(String featureNamePrefix) {
return new GeneratedResources(this.nameGenerator.withFeatureNamePrefix(featureNamePrefix),
this.resources, this.resourcesByPath, this.resourcesByOwner);
}
private record Owner(String extension, String featureNamePrefix, String featureName, @Nullable ClassName target) {
}
}
@@ -49,10 +49,22 @@ public interface GenerationContext {
*/
GeneratedClasses getGeneratedClasses();
/**
* Get the {@link GeneratedResources} used by the context.
* <p>All generated resources are written at the end of AOT processing.
* @return the generated resources
* @since 7.1
*/
GeneratedResources getGeneratedResources();
/**
* Get the {@link GeneratedFiles} used by the context.
* <p>Used to write resource, java source, or class bytecode files.
* <p>Used to write java source, resource, or class bytecode files.
* For java source and resource, use {@link #getGeneratedResources()}
* and {@link #getGeneratedClasses()}, respectively.
* @return the generated files
* @see #getGeneratedClasses()
* @see #getGeneratedResources()
*/
GeneratedFiles getGeneratedFiles();
@@ -19,6 +19,7 @@ package org.springframework.aot.generate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import org.jspecify.annotations.Nullable;
@@ -28,19 +29,21 @@ import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
/**
* Generate unique class names based on a target {@link ClassName} and a
* feature name.
* Generate unique names based on a target {@link ClassName} and a
* feature name. Support generating class names and resource paths.
*
* <p>This class is stateful, so the same instance should be used for all name
* generation.
*
* @author Phillip Webb
* @author Stephane Nicoll
* @since 6.0
* @since 7.1
*/
public final class ClassNameGenerator {
public class NameGenerator {
private static final String SEPARATOR = "__";
private static final String CLASS_NAME_SEPARATOR = "__";
private static final String RESOURCE_NAME_SEPARATOR = "-";
private static final String AOT_FEATURE = "Aot";
@@ -56,7 +59,7 @@ public final class ClassNameGenerator {
* feature name prefix.
* @param defaultTarget the default target class to use
*/
public ClassNameGenerator(ClassName defaultTarget) {
public NameGenerator(ClassName defaultTarget) {
this(defaultTarget, "");
}
@@ -66,11 +69,11 @@ public final class ClassNameGenerator {
* @param defaultTarget the default target class to use
* @param featureNamePrefix the prefix to use to qualify feature names
*/
public ClassNameGenerator(ClassName defaultTarget, String featureNamePrefix) {
public NameGenerator(ClassName defaultTarget, String featureNamePrefix) {
this(defaultTarget, featureNamePrefix, new ConcurrentHashMap<>());
}
private ClassNameGenerator(ClassName defaultTarget, String featureNamePrefix,
private NameGenerator(ClassName defaultTarget, String featureNamePrefix,
Map<String, AtomicInteger> sequenceGenerator) {
Assert.notNull(defaultTarget, "'defaultTarget' must not be null");
this.defaultTarget = defaultTarget;
@@ -100,15 +103,50 @@ public final class ClassNameGenerator {
* @return a unique generated class name
*/
public ClassName generateClassName(String featureName, @Nullable ClassName target) {
return generateSequencedClassName(getRootName(featureName, target));
return generateUniqueName(getRootClassName(featureName, target), uniqueName ->
ClassName.get(ClassUtils.getPackageName(uniqueName),
ClassUtils.getShortName(uniqueName)));
}
private String getRootName(String featureName, @Nullable ClassName target) {
private String getRootClassName(String featureName, @Nullable ClassName target) {
Assert.hasLength(featureName, "'featureName' must not be empty");
featureName = clean(featureName);
ClassName targetToUse = (target != null ? target : this.defaultTarget);
String featureNameToUse = this.featureNamePrefix + featureName;
return toName(targetToUse).replace("$", "_") + SEPARATOR + StringUtils.capitalize(featureNameToUse);
return toName(targetToUse).replace("$", "_") + CLASS_NAME_SEPARATOR + StringUtils.capitalize(featureNameToUse);
}
/**
* Generate a unique resource path based on the specified {@code extension},
* {@code featureName} and {@code target}. If the {@code target} is
* {@code null}, the configured main target of this instance is used.
* <p>The resource path is a suffixed version of the target. For instance, a
* {@code com.example.Demo} target with an {@code metadata} feature name
* and a {@code json} extension leads to a
* {@code com/example/Demo--metadata.json} generated resource path.
* The feature name is qualified by the configured feature name prefix,
* if any.
* <p>Generated resource paths are unique. If such a feature was already
* requested for this target, a counter is used to ensure uniqueness.
* @param extension the file extension
* @param featureName the name of the feature that the generated path
* supports
* @param target the class the generated resource relates to, or
* {@code null} to use the main target
* @return a unique absolute path for the resource
*/
public String generateResourcePath(String extension, String featureName, @Nullable ClassName target) {
Assert.hasLength(extension, "'extension' must not be empty");
return generateUniqueName(getRootResourceName(featureName, target),
uniqueName -> uniqueName + "." + extension);
}
private String getRootResourceName(String featureName, @Nullable ClassName target) {
Assert.hasLength(featureName, "'featureName' must not be empty");
ClassName targetToUse = (target != null ? target : this.defaultTarget);
String featureNameToUse = this.featureNamePrefix +
(this.featureNamePrefix.isEmpty() ? "" : RESOURCE_NAME_SEPARATOR) + featureName;
return toName(targetToUse).replace("$", "_").replace(".", "/") + RESOURCE_NAME_SEPARATOR + featureNameToUse;
}
private String clean(String name) {
@@ -125,26 +163,25 @@ public final class ClassNameGenerator {
return (!clean.isEmpty()) ? clean.toString() : AOT_FEATURE;
}
private ClassName generateSequencedClassName(String name) {
private <T> T generateUniqueName(String name, Function<String, T> factory) {
int sequence = this.sequenceGenerator.computeIfAbsent(name, key ->
new AtomicInteger()).getAndIncrement();
if (sequence > 0) {
name = name + sequence;
}
return ClassName.get(ClassUtils.getPackageName(name),
ClassUtils.getShortName(name));
return factory.apply(name);
}
/**
* Create a new {@link ClassNameGenerator} instance for the specified
* Create a new {@link NameGenerator} instance for the specified
* feature name prefix, keeping track of all the class names generated
* by this instance.
* @param featureNamePrefix the feature name prefix to use
* @return a new instance for the specified feature name prefix
*/
ClassNameGenerator withFeatureNamePrefix(String featureNamePrefix) {
return new ClassNameGenerator(this.defaultTarget, featureNamePrefix,
NameGenerator withFeatureNamePrefix(String featureNamePrefix) {
return new NameGenerator(this.defaultTarget, featureNamePrefix,
this.sequenceGenerator);
}
@@ -1,100 +0,0 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aot.generate;
import java.io.InputStream;
import org.junit.jupiter.api.Test;
import org.springframework.javapoet.ClassName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ClassNameGenerator}.
*
* @author Phillip Webb
*/
class ClassNameGeneratorTests {
private static final ClassName TEST_TARGET = ClassName.get("com.example", "Test");
private final ClassNameGenerator generator = new ClassNameGenerator(TEST_TARGET);
@Test
void generateClassNameWhenTargetClassIsNullUsesMainTarget() {
ClassName generated = this.generator.generateClassName("test", null);
assertThat(generated).hasToString("com.example.Test__Test");
}
@Test
void generateClassNameUseFeatureNamePrefix() {
ClassName generated = new ClassNameGenerator(TEST_TARGET, "One")
.generateClassName("test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java.io.InputStream__OneTest");
}
@Test
void generateClassNameWithNoTextFeatureNamePrefix() {
ClassName generated = new ClassNameGenerator(TEST_TARGET, " ")
.generateClassName("test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java.io.InputStream__Test");
}
@Test
void generatedClassNameWhenFeatureIsEmptyThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.generator.generateClassName("", ClassName.get(InputStream.class)))
.withMessage("'featureName' must not be empty");
}
@Test
void generatedClassNameWhenFeatureIsNotAllLettersThrowsException() {
assertThat(this.generator.generateClassName("name!", ClassName.get(InputStream.class)))
.hasToString("java.io.InputStream__Name");
assertThat(this.generator.generateClassName("1NameHere", ClassName.get(InputStream.class)))
.hasToString("java.io.InputStream__NameHere");
assertThat(this.generator.generateClassName("Y0pe", ClassName.get(InputStream.class)))
.hasToString("java.io.InputStream__YPe");
}
@Test
void generateClassNameWithClassWhenLowercaseFeatureNameGeneratesName() {
ClassName generated = this.generator.generateClassName("bytes", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java.io.InputStream__Bytes");
}
@Test
void generateClassNameWithClassWhenInnerClassGeneratesName() {
ClassName innerBean = ClassName.get("com.example", "Test", "InnerBean");
ClassName generated = this.generator.generateClassName("EventListener", innerBean);
assertThat(generated)
.hasToString("com.example.Test_InnerBean__EventListener");
}
@Test
void generateClassWithClassWhenMultipleCallsGeneratesSequencedName() {
ClassName generated1 = this.generator.generateClassName("bytes",ClassName.get(InputStream.class));
ClassName generated2 = this.generator.generateClassName("bytes", ClassName.get(InputStream.class));
ClassName generated3 = this.generator.generateClassName("bytes", ClassName.get(InputStream.class));
assertThat(generated1).hasToString("java.io.InputStream__Bytes");
assertThat(generated2).hasToString("java.io.InputStream__Bytes1");
assertThat(generated3).hasToString("java.io.InputStream__Bytes2");
}
}
@@ -41,7 +41,10 @@ class DefaultGenerationContextTests {
private static final Consumer<TypeSpec.Builder> typeSpecCustomizer = type -> {};
private final GeneratedClasses generatedClasses = new GeneratedClasses(
new ClassNameGenerator(SAMPLE_TARGET));
new NameGenerator(SAMPLE_TARGET));
private final GeneratedResources generatedResources = new GeneratedResources(
new NameGenerator(SAMPLE_TARGET));
private final InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles();
@@ -51,7 +54,7 @@ class DefaultGenerationContextTests {
@Test
void createWithOnlyGeneratedFilesCreatesContext() {
DefaultGenerationContext context = new DefaultGenerationContext(
new ClassNameGenerator(SAMPLE_TARGET), this.generatedFiles);
new NameGenerator(SAMPLE_TARGET), this.generatedFiles);
assertThat(context.getGeneratedFiles()).isSameAs(this.generatedFiles);
assertThat(context.getRuntimeHints()).isInstanceOf(RuntimeHints.class);
}
@@ -59,7 +62,7 @@ class DefaultGenerationContextTests {
@Test
void createCreatesContext() {
DefaultGenerationContext context = new DefaultGenerationContext(
this.generatedClasses, this.generatedFiles, this.runtimeHints);
this.generatedClasses, this.generatedResources, this.generatedFiles, this.runtimeHints);
assertThat(context.getGeneratedFiles()).isNotNull();
assertThat(context.getRuntimeHints()).isNotNull();
}
@@ -67,15 +70,23 @@ class DefaultGenerationContextTests {
@Test
void createWhenGeneratedClassesIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DefaultGenerationContext((GeneratedClasses) null,
.isThrownBy(() -> new DefaultGenerationContext(null, this.generatedResources,
this.generatedFiles, this.runtimeHints))
.withMessage("'generatedClasses' must not be null");
}
@Test
void createWhenGeneratedResourcesIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DefaultGenerationContext(this.generatedClasses, null,
this.generatedFiles, this.runtimeHints))
.withMessage("'generatedResources' must not be null");
}
@Test
void createWhenGeneratedFilesIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DefaultGenerationContext(this.generatedClasses,
.isThrownBy(() -> new DefaultGenerationContext(this.generatedClasses, this.generatedResources,
null, this.runtimeHints))
.withMessage("'generatedFiles' must not be null");
}
@@ -83,60 +94,74 @@ class DefaultGenerationContextTests {
@Test
void createWhenRuntimeHintsIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DefaultGenerationContext(this.generatedClasses,
.isThrownBy(() -> new DefaultGenerationContext(this.generatedClasses, this.generatedResources,
this.generatedFiles, null))
.withMessage("'runtimeHints' must not be null");
}
@Test
void getGeneratedClassesReturnsClassNameGenerator() {
void getGeneratedClassesReturnsGeneratedClasses() {
DefaultGenerationContext context = new DefaultGenerationContext(
this.generatedClasses, this.generatedFiles, this.runtimeHints);
this.generatedClasses, this.generatedResources, this.generatedFiles, this.runtimeHints);
assertThat(context.getGeneratedClasses()).isSameAs(this.generatedClasses);
}
@Test
void getGeneratedResourcesReturnsGeneratedResources() {
DefaultGenerationContext context = new DefaultGenerationContext(
this.generatedClasses, this.generatedResources, this.generatedFiles, this.runtimeHints);
assertThat(context.getGeneratedResources()).isSameAs(this.generatedResources);
}
@Test
void getGeneratedFilesReturnsGeneratedFiles() {
DefaultGenerationContext context = new DefaultGenerationContext(
this.generatedClasses, this.generatedFiles, this.runtimeHints);
this.generatedClasses, this.generatedResources, this.generatedFiles, this.runtimeHints);
assertThat(context.getGeneratedFiles()).isSameAs(this.generatedFiles);
}
@Test
void getRuntimeHintsReturnsRuntimeHints() {
DefaultGenerationContext context = new DefaultGenerationContext(
this.generatedClasses, this.generatedFiles, this.runtimeHints);
this.generatedClasses, this.generatedResources, this.generatedFiles, this.runtimeHints);
assertThat(context.getRuntimeHints()).isSameAs(this.runtimeHints);
}
@Test
void withNameUpdateNamingConvention() {
DefaultGenerationContext context = new DefaultGenerationContext(
new ClassNameGenerator(SAMPLE_TARGET), this.generatedFiles);
new NameGenerator(SAMPLE_TARGET), this.generatedFiles);
GenerationContext anotherContext = context.withName("Another");
GeneratedClass generatedClass = anotherContext.getGeneratedClasses()
.addForFeature("Test", typeSpecCustomizer);
assertThat(generatedClass.getName().simpleName()).endsWith("__AnotherTest");
GeneratedResource generatedResource = anotherContext.getGeneratedResources()
.addForFeature("txt", "test");
assertThat(generatedResource.getPath()).endsWith("-Another-test.txt");
}
@Test
void withNameKeepsTrackOfAllGeneratedFiles() {
DefaultGenerationContext context = new DefaultGenerationContext(
new ClassNameGenerator(SAMPLE_TARGET), this.generatedFiles);
new NameGenerator(SAMPLE_TARGET), this.generatedFiles);
context.getGeneratedClasses().addForFeature("Test", typeSpecCustomizer);
context.getGeneratedResources().addForFeature("txt", "test").handle(this::createTestContent);
GenerationContext anotherContext = context.withName("Another");
assertThat(anotherContext.getGeneratedClasses()).isNotSameAs(context.getGeneratedClasses());
assertThat(anotherContext.getGeneratedResources()).isNotSameAs(context.getGeneratedResources());
assertThat(anotherContext.getGeneratedFiles()).isSameAs(context.getGeneratedFiles());
assertThat(anotherContext.getRuntimeHints()).isSameAs(context.getRuntimeHints());
anotherContext.getGeneratedClasses().addForFeature("Test", typeSpecCustomizer);
anotherContext.getGeneratedResources().addForFeature("txt", "test").handle(this::createTestContent);
context.writeGeneratedContent();
assertThat(this.generatedFiles.getGeneratedFiles(Kind.SOURCE)).hasSize(2);
assertThat(this.generatedFiles.getGeneratedFiles(Kind.RESOURCE)).hasSize(2);
}
@Test
void withNameGeneratesUniqueName() {
void withNameGeneratesUniqueClassNames() {
DefaultGenerationContext context = new DefaultGenerationContext(
new ClassNameGenerator(SAMPLE_TARGET), this.generatedFiles);
new NameGenerator(SAMPLE_TARGET), this.generatedFiles);
context.withName("Test").getGeneratedClasses()
.addForFeature("Feature", typeSpecCustomizer);
context.withName("Test").getGeneratedClasses()
@@ -150,4 +175,25 @@ class DefaultGenerationContextTests {
"com/example/SampleTarget__Test2Feature.java");
}
@Test
void withNameGeneratesUniqueResourcePaths() {
DefaultGenerationContext context = new DefaultGenerationContext(
new NameGenerator(SAMPLE_TARGET), this.generatedFiles);
context.withName("Test").getGeneratedResources()
.addForFeature("txt", "feature").handle(this::createTestContent);
context.withName("Test").getGeneratedResources()
.addForFeature("txt", "feature").handle(this::createTestContent);
context.withName("Test").getGeneratedResources()
.addForFeature("txt", "feature").handle(this::createTestContent);
context.writeGeneratedContent();
assertThat(this.generatedFiles.getGeneratedFiles(Kind.RESOURCE)).containsOnlyKeys(
"com/example/SampleTarget-Test-feature.txt",
"com/example/SampleTarget-Test1-feature.txt",
"com/example/SampleTarget-Test2-feature.txt");
}
private void createTestContent(GeneratedResource.Content content) {
content.create("test");
}
}
@@ -42,12 +42,12 @@ class GeneratedClassesTests {
private static final Consumer<TypeSpec.Builder> emptyTypeCustomizer = type -> {};
private final GeneratedClasses generatedClasses = new GeneratedClasses(
new ClassNameGenerator(ClassName.get("com.example", "Test")));
new NameGenerator(ClassName.get("com.example", "Test")));
@Test
void createWhenClassNameGeneratorIsNullThrowsException() {
void createWhenNameGeneratorIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> new GeneratedClasses(null))
.withMessage("'classNameGenerator' must not be null");
.withMessage("'nameGenerator' must not be null");
}
@Test
@@ -0,0 +1,159 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aot.generate;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.aot.generate.GeneratedFiles.Kind;
import org.springframework.core.io.InputStreamSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
* Tests for {@link GeneratedResource}.
*
* @author Stephane Nicoll
*/
class GeneratedResourceTests {
private static final String TEST_RESOURCE_PATH = "com/example/one.properties";
@Test
void emptyResourceDoesNotExist() {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
assertThat(resource.getPath()).isEqualTo(TEST_RESOURCE_PATH);
assertThat(resource.hasContent()).isFalse();
}
@Test
void handleResourceHasContent() {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.create("test=1"));
assertThat(resource.hasContent()).isTrue();
}
@Test
void createResourceWithCharSequence() throws IOException {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.create("test=1"));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@Test
void createResourceWithAppendableCallback() throws IOException {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.create(appendable -> appendable.append("test").append("=").append("1")));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@Test
void createResourceWithNullInputStreamSource() throws IOException {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
byte[] content = "test=1".getBytes(StandardCharsets.UTF_8);
InputStreamSource source = () -> new ByteArrayInputStream(content);
resource.handle(writer -> writer.create(source));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@Test
void createResourceInvokedTwiceWithSameContent() {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.create("test=1"));
assertThatIllegalStateException()
.isThrownBy(() -> resource.handle(writer -> writer.create(appendable -> appendable.append("test=1"))))
.withMessage("Content for generated resource at 'com/example/one.properties' has already been set");
}
@Test
void createOrValidateWithNoContent() throws IOException {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.createOrValidate(appendable -> appendable.append("test").append("=").append("1")));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@Test
void createOrValidateWithStringInvokedTwiceWithSameContent() throws IOException {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.createOrValidate(appendable -> appendable.append("test").append("=").append("1")));
resource.handle(writer -> writer.createOrValidate("test=1"));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@Test
void createOrValidateWithAppendableInvokedTwiceWithSameContent() throws IOException {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.createOrValidate("test=1"));
resource.handle(writer -> writer.createOrValidate(appendable -> appendable.append("test").append("=").append("1")));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@Test
void createOrValidateWithInputStreamSourceInvokedTwiceWithSameContent() throws IOException {
InputStreamSource source = new AppendableConsumerInputStreamSource(appendable -> appendable.append("test").append("=").append("1"));
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.createOrValidate(source));
resource.handle(writer -> writer.createOrValidate(source));
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFileContent(Kind.RESOURCE, TEST_RESOURCE_PATH)).isEqualTo("test=1");
}
@ParameterizedTest
@ValueSource(strings = { "test=2", "", "test=11" })
void createOrValidateInvokedTwiceWithDifferentContent(String newContent) {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(writer -> writer.createOrValidate(appendable -> appendable.append("test").append("=").append("1")));
resource.handle(writer -> assertThatIllegalArgumentException()
.isThrownBy(() -> writer.createOrValidate(newContent))
.withMessage("Content for generated resource at 'com/example/one.properties' differs from the content that has already been written"));
}
@Test
void writeToWithNewResources() {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
InMemoryGeneratedFiles generatedFiles = applyToGeneratedFiles(resource);
assertThat(generatedFiles.getGeneratedFiles(Kind.RESOURCE)).isEmpty();
}
@Test
void getInputStreamWhenContentDoesNotExist() {
GeneratedResource resource = new GeneratedResource(TEST_RESOURCE_PATH);
resource.handle(content -> assertThatIllegalStateException()
.isThrownBy(content::getInputStream)
.withMessage("No content is set for GeneratedResource[path='com/example/one.properties']"));
}
InMemoryGeneratedFiles applyToGeneratedFiles(GeneratedResource resource) {
InMemoryGeneratedFiles files = new InMemoryGeneratedFiles();
resource.writeTo(files);
return files;
}
}
@@ -0,0 +1,184 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aot.generate;
import org.junit.jupiter.api.Test;
import org.springframework.javapoet.ClassName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link GeneratedResource}.
*
* @author Stephane Nicoll
*/
class GeneratedResourcesTests {
private static final String TEST_RESOURCE_PATH = "com/example/one.properties";
private static final ClassName TEST_COMPONENT = ClassName.get("org.springframework", "Example");
private final GeneratedResources generatedResources = new GeneratedResources(
new NameGenerator(ClassName.get("com.example", "Test")));
@Test
void getOrAddWhenPathIsEmptyThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.generatedResources.getOrAdd(""))
.withMessage("'path' must not be empty");
}
@Test
void getOrAddWhenPathWithLeadingSlashThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.generatedResources.getOrAdd("/resource.txt"))
.withMessage("Invalid classpath location '/resource.txt'");
}
@Test
void getOrAddForFeatureWithEmptyExtensionThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.generatedResources.getOrAddForFeature("", "test"))
.withMessage("'extension' must not be empty");
}
@Test
void getOrAddForFeatureWithEmptyFeatureThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.generatedResources.getOrAddForFeature("txt", ""))
.withMessage("'featureName' must not be empty");
}
@Test
void getOrAddForUsesProvidedPath() {
GeneratedResource generatedClass = this.generatedResources.getOrAdd("META-INF/test.properties");
assertThat(generatedClass.getPath()).isEqualTo("META-INF/test.properties");
}
@Test
void getOrAddWWhenNewReturnsEmptyGeneratedResource() {
GeneratedResource generatedResource = this.generatedResources.getOrAdd(TEST_RESOURCE_PATH);
assertThat(generatedResource.getPath()).isEqualTo(TEST_RESOURCE_PATH);
assertThat(generatedResource.hasContent()).isFalse();
}
@Test
void getOrAddWhenRepeatReturnsSameGeneratedResource() {
GeneratedResource generatedResource1 = this.generatedResources.getOrAdd(TEST_RESOURCE_PATH);
GeneratedResource generatedResource2 = this.generatedResources.getOrAdd(TEST_RESOURCE_PATH);
GeneratedResource generatedResource3 = this.generatedResources.getOrAdd(TEST_RESOURCE_PATH);
assertThat(generatedResource1).isNotNull().isSameAs(generatedResource2).isSameAs(generatedResource3);
}
@Test
void getOrAddForFeatureUsesDefaultTarget() {
GeneratedResource generatedClass = this.generatedResources.getOrAddForFeature("txt", "one");
assertThat(generatedClass.getPath()).isEqualTo("com/example/Test-one.txt");
}
@Test
void getOrAddForFeatureWhenNewReturnsGeneratedResource() {
GeneratedResource generatedClass1 = this.generatedResources.getOrAddForFeature("txt", "one");
GeneratedResource generatedClass2 = this.generatedResources.getOrAddForFeature("zip", "one");
assertThat(generatedClass1).isNotNull().isNotEqualTo(generatedClass2);
assertThat(generatedClass2).isNotNull();
}
@Test
void getOrAddForFeatureWhenRepeatReturnsSameGeneratedResource() {
GeneratedResource generatedClass1 = this.generatedResources.getOrAddForFeature("txt", "one");
GeneratedResource generatedClass2 = this.generatedResources.getOrAddForFeature("txt", "one");
GeneratedResource generatedClass3 = this.generatedResources.getOrAddForFeature("txt", "one");
assertThat(generatedClass1).isNotNull().isSameAs(generatedClass2).isSameAs(generatedClass3);
}
@Test
void getOrAddForFeatureComponentUsesTarget() {
GeneratedResource generatedClass = this.generatedResources.getOrAddForFeatureComponent("txt", "one",
TEST_COMPONENT);
assertThat(generatedClass.getPath()).isEqualTo("org/springframework/Example-one.txt");
}
@Test
void getOrAddForFeatureComponentWhenNewReturnsGeneratedResource() {
GeneratedResource generatedClass1 = this.generatedResources.getOrAddForFeatureComponent("txt", "one", TEST_COMPONENT);
GeneratedResource generatedClass2 = this.generatedResources.getOrAddForFeatureComponent("zip", "one", TEST_COMPONENT);
assertThat(generatedClass1).isNotNull().isNotEqualTo(generatedClass2);
assertThat(generatedClass2).isNotNull();
}
@Test
void getOrAddForFeatureComponentWhenRepeatReturnsSameGeneratedResource() {
GeneratedResource generatedClass1 = this.generatedResources.getOrAddForFeatureComponent("txt", "one", TEST_COMPONENT);
GeneratedResource generatedClass2 = this.generatedResources.getOrAddForFeatureComponent("txt", "one", TEST_COMPONENT);
GeneratedResource generatedClass3 = this.generatedResources.getOrAddForFeatureComponent("txt", "one", TEST_COMPONENT);
assertThat(generatedClass1).isNotNull().isSameAs(generatedClass2).isSameAs(generatedClass3);
}
@Test
void addForFeatureWithSameNameReturnsDifferentInstances() {
GeneratedResource generatedResource1 = this.generatedResources
.addForFeature("txt", "one");
GeneratedResource generatedResource2 = this.generatedResources
.addForFeature("txt", "one");
assertThat(generatedResource1).isNotSameAs(generatedResource2);
assertThat(generatedResource1.getPath()).endsWith("-one.txt");
assertThat(generatedResource2.getPath()).endsWith("-one1.txt");
}
@Test
void addForFeatureComponentWithSameNameReturnsDifferentInstances() {
GeneratedResource generatedResource1 = this.generatedResources
.addForFeatureComponent("txt", "one", TEST_COMPONENT);
GeneratedResource generatedResource2 = this.generatedResources
.addForFeatureComponent("txt", "one", TEST_COMPONENT);
assertThat(generatedResource1).isNotSameAs(generatedResource2);
assertThat(generatedResource1.getPath()).endsWith("-one.txt");
assertThat(generatedResource2.getPath()).endsWith("-one1.txt");
}
@Test
void withFeatureNameUpdatesNamingConventions() {
GeneratedResource generatedResources1 = this.generatedResources
.addForFeatureComponent("txt", "one", TEST_COMPONENT);
GeneratedResource generatedResources2 = this.generatedResources.withFeatureNamePrefix("another")
.addForFeatureComponent("txt", "one", TEST_COMPONENT);
assertThat(generatedResources1.getPath()).endsWith("Example-one.txt");
assertThat(generatedResources2.getPath()).endsWith("Example-another-one.txt");
}
@Test
void writeToAddResources() {
this.generatedResources.addForFeatureComponent("txt", "one", TEST_COMPONENT)
.handle(this::createTestContent);
this.generatedResources.addForFeatureComponent("json", "two", TEST_COMPONENT)
.handle(this::createTestContent);
InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles();
this.generatedResources.writeTo(generatedFiles);
assertThat(generatedFiles.getGeneratedFiles(GeneratedFiles.Kind.RESOURCE)).containsOnlyKeys(
"org/springframework/Example-one.txt", "org/springframework/Example-two.json");
}
private void createTestContent(GeneratedResource.Content content) {
content.create("test");
}
}
@@ -0,0 +1,168 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aot.generate;
import java.io.InputStream;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.javapoet.ClassName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link NameGenerator}.
*
* @author Phillip Webb
*/
class NameGeneratorTests {
private static final ClassName TEST_TARGET = ClassName.get("com.example", "Test");
private final NameGenerator generator = new NameGenerator(TEST_TARGET);
@Nested
class ClassNameTests {
@Test
void generateClassNameWhenTargetClassIsNullUsesMainTarget() {
ClassName generated = generator.generateClassName("test", null);
assertThat(generated).hasToString("com.example.Test__Test");
}
@Test
void generateClassNameUseFeatureNamePrefix() {
ClassName generated = new NameGenerator(TEST_TARGET, "One")
.generateClassName("test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java.io.InputStream__OneTest");
}
@Test
void generateClassNameWithNoTextFeatureNamePrefix() {
ClassName generated = new NameGenerator(TEST_TARGET, " ")
.generateClassName("test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java.io.InputStream__Test");
}
@Test
void generatedClassNameWhenFeatureIsEmptyThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> generator.generateClassName("", ClassName.get(InputStream.class)))
.withMessage("'featureName' must not be empty");
}
@Test
void generatedClassNameWhenFeatureIsNotAllLettersThrowsException() {
assertThat(generator.generateClassName("name!", ClassName.get(InputStream.class)))
.hasToString("java.io.InputStream__Name");
assertThat(generator.generateClassName("1NameHere", ClassName.get(InputStream.class)))
.hasToString("java.io.InputStream__NameHere");
assertThat(generator.generateClassName("Y0pe", ClassName.get(InputStream.class)))
.hasToString("java.io.InputStream__YPe");
}
@Test
void generateClassNameWithClassWhenLowercaseFeatureNameGeneratesName() {
ClassName generated = generator.generateClassName("bytes", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java.io.InputStream__Bytes");
}
@Test
void generateClassNameWithClassWhenInnerClassGeneratesName() {
ClassName innerBean = ClassName.get("com.example", "Test", "InnerBean");
ClassName generated = generator.generateClassName("EventListener", innerBean);
assertThat(generated)
.hasToString("com.example.Test_InnerBean__EventListener");
}
@Test
void generateClassWithClassWhenMultipleCallsGeneratesSequencedName() {
ClassName generated1 = generator.generateClassName("bytes", ClassName.get(InputStream.class));
ClassName generated2 = generator.generateClassName("bytes", ClassName.get(InputStream.class));
ClassName generated3 = generator.generateClassName("bytes", ClassName.get(InputStream.class));
assertThat(generated1).hasToString("java.io.InputStream__Bytes");
assertThat(generated2).hasToString("java.io.InputStream__Bytes1");
assertThat(generated3).hasToString("java.io.InputStream__Bytes2");
}
}
@Nested
class ResourcePathTests {
@Test
void generateResourcePathWhenTargetClassIsNullUsesMainTarget() {
String generated = generator.generateResourcePath("txt", "test", null);
assertThat(generated).isEqualTo("com/example/Test-test.txt");
}
@Test
void generateResourcePathUseFeatureNamePrefix() {
String generated = new NameGenerator(TEST_TARGET, "one")
.generateResourcePath("txt", "test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java/io/InputStream-one-test.txt");
}
@Test
void generateResourcePathWithEmptyFeatureNamePrefix() {
String generated = new NameGenerator(TEST_TARGET, "")
.generateResourcePath("txt", "test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java/io/InputStream-test.txt");
}
@Test
void generateResourcePathWithNoTextFeatureNamePrefix() {
String generated = new NameGenerator(TEST_TARGET, " ")
.generateResourcePath("txt", "test", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java/io/InputStream-test.txt");
}
@Test
void generatedResourcePathWhenExtensionIsEmptyThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> generator.generateResourcePath("", "test", ClassName.get(InputStream.class)))
.withMessage("'extension' must not be empty");
}
@Test
void generatedResourcePathWhenFeatureIsEmptyThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> generator.generateResourcePath("txt", "", ClassName.get(InputStream.class)))
.withMessage("'featureName' must not be empty");
}
@Test
void generateResourcePathWhenCaseFeatureNameGeneratesName() {
String generated = generator.generateResourcePath("txt", "Bytes", ClassName.get(InputStream.class));
assertThat(generated).hasToString("java/io/InputStream-Bytes.txt");
}
@Test
void generateResourcePathWhenMultipleCallsGeneratesSequencedName() {
String generated1 = generator.generateResourcePath("txt","bytes", ClassName.get(InputStream.class));
String generated2 = generator.generateResourcePath("txt","bytes", ClassName.get(InputStream.class));
String generated3 = generator.generateResourcePath("txt", "bytes", ClassName.get(InputStream.class));
assertThat(generated1).hasToString("java/io/InputStream-bytes.txt");
assertThat(generated2).hasToString("java/io/InputStream-bytes1.txt");
assertThat(generated3).hasToString("java/io/InputStream-bytes2.txt");
}
}
}
@@ -29,11 +29,11 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aot.AotDetector;
import org.springframework.aot.generate.ClassNameGenerator;
import org.springframework.aot.generate.DefaultGenerationContext;
import org.springframework.aot.generate.GeneratedClasses;
import org.springframework.aot.generate.GeneratedFiles;
import org.springframework.aot.generate.GenerationContext;
import org.springframework.aot.generate.NameGenerator;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.TypeReference;
@@ -378,9 +378,9 @@ public class TestContextAotGenerator {
}
DefaultGenerationContext createGenerationContext(Class<?> testClass) {
ClassNameGenerator classNameGenerator = new ClassNameGenerator(ClassName.get(testClass));
NameGenerator nameGenerator = new NameGenerator(ClassName.get(testClass));
TestContextGenerationContext generationContext =
new TestContextGenerationContext(classNameGenerator, this.generatedFiles, this.runtimeHints);
new TestContextGenerationContext(nameGenerator, this.generatedFiles, this.runtimeHints);
return generationContext.withName(nextTestContextId());
}
@@ -389,9 +389,9 @@ public class TestContextAotGenerator {
}
private void generateAotTestContextInitializerMappings(MultiValueMap<ClassName, Class<?>> initializerClassMappings) {
ClassNameGenerator classNameGenerator = new ClassNameGenerator(ClassName.get(AotTestContextInitializers.class));
NameGenerator nameGenerator = new NameGenerator(ClassName.get(AotTestContextInitializers.class));
DefaultGenerationContext generationContext =
new DefaultGenerationContext(classNameGenerator, this.generatedFiles, this.runtimeHints);
new DefaultGenerationContext(nameGenerator, this.generatedFiles, this.runtimeHints);
GeneratedClasses generatedClasses = generationContext.getGeneratedClasses();
AotTestContextInitializersCodeGenerator codeGenerator =
@@ -402,9 +402,9 @@ public class TestContextAotGenerator {
}
private void generateAotTestAttributeMappings() {
ClassNameGenerator classNameGenerator = new ClassNameGenerator(ClassName.get(AotTestAttributes.class));
NameGenerator nameGenerator = new NameGenerator(ClassName.get(AotTestAttributes.class));
DefaultGenerationContext generationContext =
new DefaultGenerationContext(classNameGenerator, this.generatedFiles, this.runtimeHints);
new DefaultGenerationContext(nameGenerator, this.generatedFiles, this.runtimeHints);
GeneratedClasses generatedClasses = generationContext.getGeneratedClasses();
Map<String, String> attributes = AotTestAttributesFactory.getAttributes();
@@ -18,9 +18,9 @@ package org.springframework.test.context.aot;
import org.jspecify.annotations.Nullable;
import org.springframework.aot.generate.ClassNameGenerator;
import org.springframework.aot.generate.DefaultGenerationContext;
import org.springframework.aot.generate.GeneratedFiles;
import org.springframework.aot.generate.NameGenerator;
import org.springframework.aot.hint.RuntimeHints;
/**
@@ -37,16 +37,17 @@ class TestContextGenerationContext extends DefaultGenerationContext {
/**
* Create a new {@link TestContextGenerationContext} instance backed by the
* specified {@link ClassNameGenerator}, {@link GeneratedFiles}, and
* specified {@link NameGenerator}, {@link GeneratedFiles}, and
* {@link RuntimeHints}.
* @param classNameGenerator the naming convention to use for generated class names
* @param nameGenerator the naming convention to use for generated classes
* and resources
* @param generatedFiles the generated files
* @param runtimeHints the runtime hints
*/
TestContextGenerationContext(
ClassNameGenerator classNameGenerator, GeneratedFiles generatedFiles, RuntimeHints runtimeHints) {
NameGenerator nameGenerator, GeneratedFiles generatedFiles, RuntimeHints runtimeHints) {
super(classNameGenerator, generatedFiles, runtimeHints);
super(nameGenerator, generatedFiles, runtimeHints);
this.featureName = null;
}