diff --git a/spring-core/src/main/java/org/springframework/aot/generator/DefaultGeneratedTypeContext.java b/spring-core/src/main/java/org/springframework/aot/generator/DefaultGeneratedTypeContext.java new file mode 100644 index 00000000000..88c4a3e4d45 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generator/DefaultGeneratedTypeContext.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2022 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.generator; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.javapoet.JavaFile; + +/** + * Default {@link GeneratedTypeContext} implementation. + * + * @author Stephane Nicoll + * @since 6.0 + */ +public class DefaultGeneratedTypeContext implements GeneratedTypeContext { + + private final String packageName; + + private final RuntimeHints runtimeHints; + + private final Function generatedTypeFactory; + + private final Map generatedTypes; + + /** + * Create a context targeting the specified package name and using the specified + * factory to create a {@link GeneratedType} per requested package name. + * @param packageName the main package name + * @param generatedTypeFactory the factory to use to create a {@link GeneratedType} + * based on a package name. + */ + public DefaultGeneratedTypeContext(String packageName, Function generatedTypeFactory) { + this.packageName = packageName; + this.runtimeHints = new RuntimeHints(); + this.generatedTypeFactory = generatedTypeFactory; + this.generatedTypes = new LinkedHashMap<>(); + } + + @Override + public RuntimeHints runtimeHints() { + return this.runtimeHints; + } + + @Override + public GeneratedType getGeneratedType(String packageName) { + return this.generatedTypes.computeIfAbsent(packageName, this.generatedTypeFactory); + } + + @Override + public GeneratedType getMainGeneratedType() { + return getGeneratedType(this.packageName); + } + + /** + * Specify if a {@link GeneratedType} for the specified package name is registered. + * @param packageName the package name to use + * @return {@code true} if a type is registered for that package + */ + public boolean hasGeneratedType(String packageName) { + return this.generatedTypes.containsKey(packageName); + } + + /** + * Return the list of {@link JavaFile} of known generated type. + * @return the java files of bootstrap classes in this instance + */ + public List toJavaFiles() { + return this.generatedTypes.values().stream() + .map(GeneratedType::toJavaFile) + .collect(Collectors.toList()); + } + +} diff --git a/spring-core/src/main/java/org/springframework/aot/generator/GeneratedType.java b/spring-core/src/main/java/org/springframework/aot/generator/GeneratedType.java new file mode 100644 index 00000000000..d50edf1b0f5 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generator/GeneratedType.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2022 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.generator; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import javax.lang.model.element.Modifier; + +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.JavaFile; +import org.springframework.javapoet.MethodSpec; +import org.springframework.javapoet.TypeSpec; + +/** + * Wrapper for a generated {@linkplain TypeSpec type}. + * + * @author Stephane Nicoll + * @since 6.0 + */ +public class GeneratedType { + + private final ClassName className; + + private final TypeSpec.Builder type; + + private final List methods; + + GeneratedType(ClassName className, Consumer type) { + this.className = className; + this.type = TypeSpec.classBuilder(className); + type.accept(this.type); + this.methods = new ArrayList<>(); + } + + /** + * Create an instance for the specified {@link ClassName}, customizing the type with + * the specified {@link Consumer consumer callback}. + * @param className the class name + * @param type a callback to customize the type, i.e. to change default modifiers + * @return a new {@link GeneratedType} + */ + public static GeneratedType of(ClassName className, Consumer type) { + return new GeneratedType(className, type); + } + + /** + * Create an instance for the specified {@link ClassName}, as a {@code public} type. + * @param className the class name + * @return a new {@link GeneratedType} + */ + public static GeneratedType of(ClassName className) { + return of(className, type -> type.addModifiers(Modifier.PUBLIC)); + } + + /** + * Return the {@link ClassName} of this instance. + * @return the class name + */ + public ClassName getClassName() { + return this.className; + } + + /** + * Customize the type of this instance. + * @param type the consumer of the type builder + * @return this for method chaining + */ + public GeneratedType customizeType(Consumer type) { + type.accept(this.type); + return this; + } + + /** + * Add a method using the state of the specified {@link MethodSpec.Builder}, + * updating the name of the method if a similar method already exists. + * @param method a method builder representing the method to add + * @return the added method + */ + public MethodSpec addMethod(MethodSpec.Builder method) { + MethodSpec methodToAdd = createUniqueNameIfNecessary(method.build()); + this.methods.add(methodToAdd); + return methodToAdd; + } + + /** + * Return a {@link JavaFile} with the state of this instance. + * @return a java file + */ + public JavaFile toJavaFile() { + return JavaFile.builder(this.className.packageName(), + this.type.addMethods(this.methods).build()).indent("\t").build(); + } + + private MethodSpec createUniqueNameIfNecessary(MethodSpec method) { + List candidates = this.methods.stream().filter(isSimilar(method)).toList(); + if (candidates.isEmpty()) { + return method; + } + MethodSpec updatedMethod = method.toBuilder().setName(method.name + "_").build(); + return createUniqueNameIfNecessary(updatedMethod); + } + + private Predicate isSimilar(MethodSpec method) { + return candidate -> method.name.equals(candidate.name) + && method.parameters.size() == candidate.parameters.size(); + } + +} diff --git a/spring-core/src/main/java/org/springframework/aot/generator/GeneratedTypeContext.java b/spring-core/src/main/java/org/springframework/aot/generator/GeneratedTypeContext.java new file mode 100644 index 00000000000..679423b4fef --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generator/GeneratedTypeContext.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2022 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.generator; + +import org.springframework.aot.hint.RuntimeHints; + +/** + * Context passed to object that can generate code, giving access to a main + * {@link GeneratedType} as well as to a {@link GeneratedType} in a given + * package if privileged access is required. + * + * @author Stephane Nicoll + * @since 6.0 + */ +public interface GeneratedTypeContext { + + /** + * Return the {@link RuntimeHints} instance to use to contribute hints for + * generated types. + * @return the runtime hints + */ + RuntimeHints runtimeHints(); + + /** + * Return a {@link GeneratedType} for the specified package. If it does not + * exist, it is created. + * @param packageName the package name to use + * @return a generated type + */ + GeneratedType getGeneratedType(String packageName); + + /** + * Return the main {@link GeneratedType}. + * @return the generated type for the target package + */ + GeneratedType getMainGeneratedType(); + +} diff --git a/spring-core/src/test/java/org/springframework/aot/generator/DefaultGeneratedTypeContextTests.java b/spring-core/src/test/java/org/springframework/aot/generator/DefaultGeneratedTypeContextTests.java new file mode 100644 index 00000000000..f030467ae31 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/aot/generator/DefaultGeneratedTypeContextTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2022 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.generator; + +import javax.lang.model.element.Modifier; + +import org.junit.jupiter.api.Test; + +import org.springframework.javapoet.ClassName; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultGeneratedTypeContext}. + * + * @author Stephane Nicoll + */ +class DefaultGeneratedTypeContextTests { + + @Test + void runtimeHints() { + DefaultGeneratedTypeContext context = createComAcmeContext(); + assertThat(context.runtimeHints()).isNotNull(); + } + + @Test + void getGeneratedTypeMatchesGetMainGeneratedTypeForMainPackage() { + DefaultGeneratedTypeContext context = createComAcmeContext(); + assertThat(context.getMainGeneratedType().getClassName()).isEqualTo(ClassName.get("com.acme", "Main")); + assertThat(context.getGeneratedType("com.acme")).isSameAs(context.getMainGeneratedType()); + } + + @Test + void getMainGeneratedTypeIsLazilyCreated() { + DefaultGeneratedTypeContext context = createComAcmeContext(); + assertThat(context.hasGeneratedType("com.acme")).isFalse(); + context.getMainGeneratedType(); + assertThat(context.hasGeneratedType("com.acme")).isTrue(); + } + + @Test + void getGeneratedTypeRegisterInstance() { + DefaultGeneratedTypeContext context = createComAcmeContext(); + assertThat(context.hasGeneratedType("com.example")).isFalse(); + GeneratedType generatedType = context.getGeneratedType("com.example"); + assertThat(generatedType).isNotNull(); + assertThat(generatedType.getClassName().simpleName()).isEqualTo("Main"); + assertThat(context.hasGeneratedType("com.example")).isTrue(); + } + + @Test + void getGeneratedTypeReuseInstance() { + DefaultGeneratedTypeContext context = createComAcmeContext(); + GeneratedType generatedType = context.getGeneratedType("com.example"); + assertThat(generatedType.getClassName().packageName()).isEqualTo("com.example"); + assertThat(context.getGeneratedType("com.example")).isSameAs(generatedType); + } + + @Test + void toJavaFilesWithNoTypeIsEmpty() { + DefaultGeneratedTypeContext writerContext = createComAcmeContext(); + assertThat(writerContext.toJavaFiles()).hasSize(0); + } + + @Test + void toJavaFilesWithDefaultTypeIsAddedLazily() { + DefaultGeneratedTypeContext writerContext = createComAcmeContext(); + writerContext.getMainGeneratedType(); + assertThat(writerContext.toJavaFiles()).hasSize(1); + } + + @Test + void toJavaFilesWithDefaultTypeAndAdditionaTypes() { + DefaultGeneratedTypeContext writerContext = createComAcmeContext(); + writerContext.getGeneratedType("com.example"); + writerContext.getGeneratedType("com.another"); + writerContext.getGeneratedType("com.another.another"); + assertThat(writerContext.toJavaFiles()).hasSize(3); + } + + private DefaultGeneratedTypeContext createComAcmeContext() { + return new DefaultGeneratedTypeContext("com.acme", packageName -> + GeneratedType.of(ClassName.get(packageName, "Main"), type -> type.addModifiers(Modifier.PUBLIC))); + } + +} diff --git a/spring-core/src/test/java/org/springframework/aot/generator/GeneratedTypeTests.java b/spring-core/src/test/java/org/springframework/aot/generator/GeneratedTypeTests.java new file mode 100644 index 00000000000..d3044bc04c4 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/aot/generator/GeneratedTypeTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2002-2022 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.generator; + +import java.io.IOException; +import java.io.StringWriter; + +import javax.lang.model.element.Modifier; + +import org.junit.jupiter.api.Test; + +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.FieldSpec; +import org.springframework.javapoet.MethodSpec; +import org.springframework.javapoet.TypeName; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GeneratedType}. + * + * @author Stephane Nicoll + */ +class GeneratedTypeTests { + + private static final ClassName TEST_CLASS_NAME = ClassName.get("com.acme", "Test"); + + @Test + void className() { + GeneratedType generatedType = new GeneratedType(TEST_CLASS_NAME, + type -> type.addModifiers(Modifier.STATIC)); + assertThat(generatedType.getClassName()).isEqualTo(TEST_CLASS_NAME); + assertThat(generateCode(generatedType)).contains("static class Test {"); + } + + @Test + void createWithCustomField() { + GeneratedType generatedType = new GeneratedType(TEST_CLASS_NAME, + type -> type.addField(FieldSpec.builder(TypeName.BOOLEAN, "enabled").build())); + assertThat(generateCode(generatedType)).contains("boolean enabled;"); + } + + @Test + void customizeType() { + GeneratedType generatedType = createTestGeneratedType(); + generatedType.customizeType(type -> type.addJavadoc("Test javadoc.")) + .customizeType(type -> type.addJavadoc(" Another test javadoc")); + assertThat(generateCode(generatedType)).containsSequence( + "/**\n", + " * Test javadoc. Another test javadoc\n", + " */"); + } + + @Test + void addMethod() { + GeneratedType generatedType = createTestGeneratedType(); + generatedType.addMethod(MethodSpec.methodBuilder("test").returns(Integer.class) + .addCode(CodeBlock.of("return 42;"))); + assertThat(generateCode(generatedType)).containsSequence( + "\tInteger test() {\n", + "\t\treturn 42;\n", + "\t}"); + } + + @Test + void addMultipleMethods() { + GeneratedType generatedType = createTestGeneratedType(); + generatedType.addMethod(MethodSpec.methodBuilder("first")); + generatedType.addMethod(MethodSpec.methodBuilder("second")); + assertThat(generateCode(generatedType)) + .containsSequence("\tvoid first() {\n", "\t}") + .containsSequence("\tvoid second() {\n", "\t}"); + } + + @Test + void addSimilarMethodGenerateUniqueNames() { + GeneratedType generatedType = createTestGeneratedType(); + MethodSpec firstMethod = generatedType.addMethod(MethodSpec.methodBuilder("test")); + MethodSpec secondMethod = generatedType.addMethod(MethodSpec.methodBuilder("test")); + MethodSpec thirdMethod = generatedType.addMethod(MethodSpec.methodBuilder("test")); + assertThat(firstMethod.name).isEqualTo("test"); + assertThat(secondMethod.name).isEqualTo("test_"); + assertThat(thirdMethod.name).isEqualTo("test__"); + assertThat(generateCode(generatedType)) + .containsSequence("\tvoid test() {\n", "\t}") + .containsSequence("\tvoid test_() {\n", "\t}") + .containsSequence("\tvoid test__() {\n", "\t}"); + } + + @Test + void addMethodWithSameNameAndDifferentArgumentsDoesNotChangeName() { + GeneratedType generatedType = createTestGeneratedType(); + generatedType.addMethod(MethodSpec.methodBuilder("test")); + MethodSpec secondMethod = generatedType.addMethod(MethodSpec.methodBuilder("test") + .addParameter(String.class, "param")); + assertThat(secondMethod.name).isEqualTo("test"); + } + + private GeneratedType createTestGeneratedType() { + return GeneratedType.of(TEST_CLASS_NAME); + } + + private String generateCode(GeneratedType generatedType) { + try { + StringWriter out = new StringWriter(); + generatedType.toJavaFile().writeTo(out); + return out.toString(); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + +}