Add support for generating classes

Add `ClassGenerator` and `GeneratedClass` which can be used to
generated classes that will ultimately be written to a `JavaFile`.

See gh-28414
This commit is contained in:
Phillip Webb 2022-04-26 20:51:31 -07:00
parent 55d7f7a014
commit d5374550e5
5 changed files with 487 additions and 0 deletions

View File

@ -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<String> getReservedMethodNames() {
return Collections.emptySet();
}
}
}

View File

@ -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;
}
}

View File

@ -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<Owner, GeneratedClass> 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<GeneratedClass> 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) {
}
}

View File

@ -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();
}
}

View File

@ -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 {
}
}