diff --git a/spring-core/src/main/java/org/springframework/aot/generate/ClassGenerator.java b/spring-core/src/main/java/org/springframework/aot/generate/ClassGenerator.java new file mode 100644 index 00000000000..eaa47232c99 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generate/ClassGenerator.java @@ -0,0 +1,87 @@ +/* + * 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.generate; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.JavaFile; + +/** + * Generates new {@link GeneratedClass} instances. + * + * @author Phillip Webb + * @since 6.0 + * @see GeneratedMethods + */ +public interface ClassGenerator { + + /** + * Get or generate a new {@link GeneratedClass} for a given java file + * generator, target and feature name. + * @param javaFileGenerator the java file generator + * @param target the target of the newly generated class + * @param featureName the name of the feature that the generated class + * supports + * @return a {@link GeneratedClass} instance + */ + GeneratedClass getOrGenerateClass(JavaFileGenerator javaFileGenerator, + Class target, String featureName); + + /** + * Get or generate a new {@link GeneratedClass} for a given java file + * generator, target and feature name. + * @param javaFileGenerator the java file generator + * @param target the target of the newly generated class + * @param featureName the name of the feature that the generated class + * supports + * @return a {@link GeneratedClass} instance + */ + GeneratedClass getOrGenerateClass(JavaFileGenerator javaFileGenerator, String target, + String featureName); + + + /** + * Strategy used to generate the java file for the generated class. + * Implementations of this interface are included as part of the key used to + * identify classes that have already been created and as such should be + * static final instances or implement a valid + * {@code equals}/{@code hashCode}. + */ + @FunctionalInterface + interface JavaFileGenerator { + + /** + * Generate the file {@link JavaFile} to be written. + * @param className the class name of the file + * @param methods the generated methods that must be included + * @return the generated files + */ + JavaFile generateJavaFile(ClassName className, GeneratedMethods methods); + + /** + * Return method names that must not be generated. + * @return the reserved method names + */ + default Collection getReservedMethodNames() { + return Collections.emptySet(); + } + + } + +} diff --git a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedClass.java b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedClass.java new file mode 100644 index 00000000000..caef4730e0e --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedClass.java @@ -0,0 +1,84 @@ +/* + * 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.generate; + +import org.springframework.aot.generate.ClassGenerator.JavaFileGenerator; +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.JavaFile; +import org.springframework.util.Assert; + +/** + * A generated class. + * + * @author Phillip Webb + * @since 6.0 + * @see GeneratedClasses + * @see ClassGenerator + */ +public final class GeneratedClass { + + private final JavaFileGenerator JavaFileGenerator; + + private final ClassName name; + + private final GeneratedMethods methods; + + + /** + * Create a new {@link GeneratedClass} instance with the given name. This + * constructor is package-private since names should only be generated via a + * {@link GeneratedClasses}. + * @param name the generated name + */ + GeneratedClass(JavaFileGenerator javaFileGenerator, ClassName name) { + MethodNameGenerator methodNameGenerator = new MethodNameGenerator( + javaFileGenerator.getReservedMethodNames()); + this.JavaFileGenerator = javaFileGenerator; + this.name = name; + this.methods = new GeneratedMethods(methodNameGenerator); + } + + + /** + * Return the name of the generated class. + * @return the name of the generated class + */ + public ClassName getName() { + return this.name; + } + + /** + * Return the method generator that can be used for this generated class. + * @return the method generator + */ + public MethodGenerator getMethodGenerator() { + return this.methods; + } + + JavaFile generateJavaFile() { + JavaFile javaFile = this.JavaFileGenerator.generateJavaFile(this.name, + this.methods); + Assert.state(this.name.packageName().equals(javaFile.packageName), + () -> "Generated JavaFile should be in package '" + + this.name.packageName() + "'"); + Assert.state(this.name.simpleName().equals(javaFile.typeSpec.name), + () -> "Generated JavaFile should be named '" + this.name.simpleName() + + "'"); + return javaFile; + } + +} diff --git a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedClasses.java b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedClasses.java new file mode 100644 index 00000000000..c9ab32dad02 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedClasses.java @@ -0,0 +1,94 @@ +/* + * 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.generate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.util.Assert; + +/** + * A managed collection of generated classes. + * + * @author Phillip Webb + * @since 6.0 + * @see GeneratedClass + */ +public class GeneratedClasses implements ClassGenerator { + + private final ClassNameGenerator classNameGenerator; + + private final Map classes = new ConcurrentHashMap<>(); + + + public GeneratedClasses(ClassNameGenerator classNameGenerator) { + Assert.notNull(classNameGenerator, "'classNameGenerator' must not be null"); + this.classNameGenerator = classNameGenerator; + } + + + @Override + public GeneratedClass getOrGenerateClass(JavaFileGenerator javaFileGenerator, + Class target, String featureName) { + + Assert.notNull(javaFileGenerator, "'javaFileGenerator' must not be null"); + Assert.notNull(target, "'target' must not be null"); + Assert.hasLength(featureName, "'featureName' must not be empty"); + Owner owner = new Owner(javaFileGenerator, target.getName(), featureName); + return this.classes.computeIfAbsent(owner, + key -> new GeneratedClass(javaFileGenerator, + this.classNameGenerator.generateClassName(target, featureName))); + } + + @Override + public GeneratedClass getOrGenerateClass(JavaFileGenerator javaFileGenerator, + String target, String featureName) { + + Assert.notNull(javaFileGenerator, "'javaFileGenerator' must not be null"); + Assert.hasLength(target, "'target' must not be empty"); + Assert.hasLength(featureName, "'featureName' must not be empty"); + Owner owner = new Owner(javaFileGenerator, target, featureName); + return this.classes.computeIfAbsent(owner, + key -> new GeneratedClass(javaFileGenerator, + this.classNameGenerator.generateClassName(target, featureName))); + } + + /** + * Write generated Spring {@code .factories} files to the given + * {@link GeneratedFiles} instance. + * @param generatedFiles where to write the generated files + * @throws IOException on IO error + */ + public void writeTo(GeneratedFiles generatedFiles) throws IOException { + Assert.notNull(generatedFiles, "'generatedFiles' must not be null"); + List generatedClasses = new ArrayList<>(this.classes.values()); + generatedClasses.sort(Comparator.comparing(GeneratedClass::getName)); + for (GeneratedClass generatedClass : generatedClasses) { + generatedFiles.addSourceFile(generatedClass.generateJavaFile()); + } + } + + private record Owner(JavaFileGenerator javaFileGenerator, String target, + String featureName) { + + } + +} diff --git a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.java b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.java new file mode 100644 index 00000000000..35ef736c8c0 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.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.generate; + +import org.junit.jupiter.api.Test; + +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.JavaFile; +import org.springframework.javapoet.TypeSpec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link GeneratedClass}. + * + * @author Phillip Webb + */ +class GeneratedClassTests { + + @Test + void getNameReturnsName() { + ClassName name = ClassName.bestGuess("com.example.Test"); + GeneratedClass generatedClass = new GeneratedClass(this::generateJavaFile, name); + assertThat(generatedClass.getName()).isSameAs(name); + } + + @Test + void generateJavaFileSuppliesGeneratedMethods() { + ClassName name = ClassName.bestGuess("com.example.Test"); + GeneratedClass generatedClass = new GeneratedClass(this::generateJavaFile, name); + MethodGenerator methodGenerator = generatedClass.getMethodGenerator(); + methodGenerator.generateMethod("test") + .using(builder -> builder.addJavadoc("Test Method")); + assertThat(generatedClass.generateJavaFile().toString()).contains("Test Method"); + } + + @Test + void generateJavaFileWhenHasBadPackageThrowsException() { + ClassName name = ClassName.bestGuess("com.example.Test"); + GeneratedClass generatedClass = new GeneratedClass( + this::generateBadPackageJavaFile, name); + assertThatIllegalStateException() + .isThrownBy( + () -> assertThat(generatedClass.generateJavaFile().toString())) + .withMessageContaining("should be in package"); + } + + @Test + void generateJavaFileWhenHasBadNameThrowsException() { + ClassName name = ClassName.bestGuess("com.example.Test"); + GeneratedClass generatedClass = new GeneratedClass(this::generateBadNameJavaFile, + name); + assertThatIllegalStateException() + .isThrownBy( + () -> assertThat(generatedClass.generateJavaFile().toString())) + .withMessageContaining("should be named"); + } + + private JavaFile generateJavaFile(ClassName className, GeneratedMethods methods) { + TypeSpec.Builder classBuilder = TypeSpec.classBuilder(className); + methods.doWithMethodSpecs(classBuilder::addMethod); + return JavaFile.builder(className.packageName(), classBuilder.build()).build(); + } + + private JavaFile generateBadPackageJavaFile(ClassName className, + GeneratedMethods methods) { + TypeSpec.Builder classBuilder = TypeSpec.classBuilder(className); + return JavaFile.builder("naughty", classBuilder.build()).build(); + } + + private JavaFile generateBadNameJavaFile(ClassName className, + GeneratedMethods methods) { + TypeSpec.Builder classBuilder = TypeSpec.classBuilder("Naughty"); + return JavaFile.builder(className.packageName(), classBuilder.build()).build(); + } + +} diff --git a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassesTests.java b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassesTests.java new file mode 100644 index 00000000000..94a3fc1e558 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassesTests.java @@ -0,0 +1,130 @@ +/* + * 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.generate; + +import org.junit.jupiter.api.Test; + +import org.springframework.aot.generate.ClassGenerator.JavaFileGenerator; +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.JavaFile; +import org.springframework.javapoet.TypeSpec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link GeneratedClasses}. + * + * @author Phillip Webb + */ +class GeneratedClassesTests { + + private GeneratedClasses generatedClasses = new GeneratedClasses( + new ClassNameGenerator()); + + private static final JavaFileGenerator JAVA_FILE_GENERATOR = GeneratedClassesTests::generateJavaFile; + + @Test + void createWhenClassNameGeneratorIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GeneratedClasses(null)) + .withMessage("'classNameGenerator' must not be null"); + } + + @Test + void getOrGenerateWithClassTargetWhenJavaFileGeneratorIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generatedClasses.getOrGenerateClass(null, + TestTarget.class, "test")) + .withMessage("'javaFileGenerator' must not be null"); + } + + @Test + void getOrGenerateWithClassTargetWhenTargetIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generatedClasses + .getOrGenerateClass(JAVA_FILE_GENERATOR, (Class) null, "test")) + .withMessage("'target' must not be null"); + } + + @Test + void getOrGenerateWithClassTargetWhenFeatureIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generatedClasses + .getOrGenerateClass(JAVA_FILE_GENERATOR, TestTarget.class, null)) + .withMessage("'featureName' must not be empty"); + } + + @Test + void getOrGenerateWithStringTargetWhenJavaFileGeneratorIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generatedClasses.getOrGenerateClass(null, + TestTarget.class.getName(), "test")) + .withMessage("'javaFileGenerator' must not be null"); + } + + @Test + void getOrGenerateWithStringTargetWhenTargetIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generatedClasses + .getOrGenerateClass(JAVA_FILE_GENERATOR, (String) null, "test")) + .withMessage("'target' must not be empty"); + } + + @Test + void getOrGenerateWithStringTargetWhenFeatureIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generatedClasses.getOrGenerateClass( + JAVA_FILE_GENERATOR, TestTarget.class.getName(), null)) + .withMessage("'featureName' must not be empty"); + } + + @Test + void getOrGenerateWhenNewReturnsGeneratedMethod() { + GeneratedClass generatedClass1 = this.generatedClasses + .getOrGenerateClass(JAVA_FILE_GENERATOR, TestTarget.class, "one"); + GeneratedClass generatedClass2 = this.generatedClasses.getOrGenerateClass( + JAVA_FILE_GENERATOR, TestTarget.class.getName(), "two"); + assertThat(generatedClass1).isNotNull().isNotEqualTo(generatedClass2); + assertThat(generatedClass2).isNotNull(); + } + + @Test + void getOrGenerateWhenRepeatReturnsSameGeneratedMethod() { + GeneratedClasses generated = this.generatedClasses; + GeneratedClass generatedClass1 = generated.getOrGenerateClass(JAVA_FILE_GENERATOR, + TestTarget.class, "one"); + GeneratedClass generatedClass2 = generated.getOrGenerateClass(JAVA_FILE_GENERATOR, + TestTarget.class, "one"); + GeneratedClass generatedClass3 = generated.getOrGenerateClass(JAVA_FILE_GENERATOR, + TestTarget.class.getName(), "one"); + GeneratedClass generatedClass4 = generated.getOrGenerateClass(JAVA_FILE_GENERATOR, + TestTarget.class, "two"); + assertThat(generatedClass1).isNotNull().isSameAs(generatedClass2) + .isSameAs(generatedClass3).isNotSameAs(generatedClass4); + } + + static JavaFile generateJavaFile(ClassName className, + GeneratedMethods generatedMethods) { + TypeSpec typeSpec = TypeSpec.classBuilder(className).addJavadoc("Test").build(); + return JavaFile.builder(className.packageName(), typeSpec).build(); + } + + private static class TestTarget { + + } + +}