From ca2b5e068bbecaac9f4170bc7c17d83008900f4d Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Wed, 13 Apr 2022 18:13:34 -0700 Subject: [PATCH] Add support for generating class names Add `ClassNameGenerator` to support generation of class names. See gh-28414 --- .../aot/generate/ClassNameGenerator.java | 108 ++++++++++++++++ .../aot/generate/ClassNameGeneratorTests.java | 119 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 spring-core/src/main/java/org/springframework/aot/generate/ClassNameGenerator.java create mode 100644 spring-core/src/test/java/org/springframework/aot/generate/ClassNameGeneratorTests.java diff --git a/spring-core/src/main/java/org/springframework/aot/generate/ClassNameGenerator.java b/spring-core/src/main/java/org/springframework/aot/generate/ClassNameGenerator.java new file mode 100644 index 0000000000..5ff7f02b26 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/generate/ClassNameGenerator.java @@ -0,0 +1,108 @@ +/* + * 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.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.javapoet.ClassName; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Generates unique class names that can be used in ahead-of-time generated + * source code. This class is stateful so the same instance should be used for + * all name generation. Most commonly the class name generator is obtained via a + * {@link GenerationContext}. + * + * @author Phillip Webb + * @since 6.0 + * @see GeneratedClassName + */ +public final class ClassNameGenerator { + + private static final String SEPARATOR = "__"; + + private static final String AOT_PACKAGE = "__"; + + + private final Map sequenceGenerator = new ConcurrentHashMap<>(); + + + /** + * Generate a new class name for the given {@code target} / + * {@code featureName} combination. + * @param target the target of the newly generated class + * @param featureName the name of the feature that the generated class + * supports + * @return a unique generated class name + */ + public ClassName generateClassName(Class target, String featureName) { + Assert.notNull(target, "'target' must not be null"); + String rootName = target.getName().replace("$", "_"); + return generateSequencedClassName(rootName, featureName); + } + + /** + * Generate a new class name for the given {@code name} / + * {@code featureName} combination. + * @param target the target of the newly generated class. When possible, + * this should be a class name + * @param featureName the name of the feature that the generated class + * supports + * @return a unique generated class name + */ + public ClassName generateClassName(String target, String featureName) { + Assert.hasLength(target, "'target' must not be empty"); + target = clean(target); + String rootName = AOT_PACKAGE + "." + ((!target.isEmpty()) ? target : "Aot"); + return generateSequencedClassName(rootName, featureName); + } + + private String clean(String name) { + StringBuilder rootName = new StringBuilder(); + boolean lastNotLetter = true; + for (char ch : name.toCharArray()) { + if (!Character.isLetter(ch)) { + lastNotLetter = true; + continue; + } + rootName.append(lastNotLetter ? Character.toUpperCase(ch) : ch); + lastNotLetter = false; + } + return rootName.toString(); + } + + private ClassName generateSequencedClassName(String rootName, String featureName) { + Assert.hasLength(featureName, "'featureName' must not be empty"); + Assert.isTrue(featureName.chars().allMatch(Character::isLetter), + "'featureName' must contain only letters"); + String name = addSequence( + rootName + SEPARATOR + StringUtils.capitalize(featureName)); + return ClassName.get(ClassUtils.getPackageName(name), + ClassUtils.getShortName(name)); + } + + private String addSequence(String name) { + int sequence = this.sequenceGenerator + .computeIfAbsent(name, key -> new AtomicInteger()).getAndIncrement(); + return (sequence > 0) ? name + sequence : name; + } + +} diff --git a/spring-core/src/test/java/org/springframework/aot/generate/ClassNameGeneratorTests.java b/spring-core/src/test/java/org/springframework/aot/generate/ClassNameGeneratorTests.java new file mode 100644 index 0000000000..7f42e4859c --- /dev/null +++ b/spring-core/src/test/java/org/springframework/aot/generate/ClassNameGeneratorTests.java @@ -0,0 +1,119 @@ +/* + * 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.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 final ClassNameGenerator generator = new ClassNameGenerator(); + + @Test + void generateClassNameWhenTargetClassIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> this.generator.generateClassName((Class) null, "Test")) + .withMessage("'target' must not be null"); + } + + @Test + void generateClassNameWhenTargetStringIsEmptyThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generator.generateClassName("", "Test")) + .withMessage("'target' must not be empty"); + } + + @Test + void generatedClassNameWhenFeatureIsEmptyThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.generator.generateClassName(InputStream.class, "")) + .withMessage("'featureName' must not be empty"); + } + + @Test + void generatedClassNameWhenFeatureIsNotAllLettersThrowsException() { + String expectedMessage = "'featureName' must contain only letters"; + assertThatIllegalArgumentException().isThrownBy( + () -> this.generator.generateClassName(InputStream.class, "noway!")) + .withMessage(expectedMessage); + assertThatIllegalArgumentException().isThrownBy( + () -> this.generator.generateClassName(InputStream.class, "1WontWork")) + .withMessage(expectedMessage); + assertThatIllegalArgumentException() + .isThrownBy( + () -> this.generator.generateClassName(InputStream.class, "N0pe")) + .withMessage(expectedMessage); + } + + @Test + void generateClassNameWithClassWhenLowercaseFeatureNameGeneratesName() { + ClassName generated = this.generator.generateClassName(InputStream.class, + "bytes"); + assertThat(generated).hasToString("java.io.InputStream__Bytes"); + } + + @Test + void generateClassNameWithClassWhenInnerClassGeneratesName() { + ClassName generated = this.generator.generateClassName(TestBean.class, + "EventListener"); + assertThat(generated).hasToString( + "org.springframework.aot.generate.ClassNameGeneratorTests_TestBean__EventListener"); + } + + @Test + void generateClassWithClassWhenMultipleCallsGeneratesSequencedName() { + ClassName generated1 = this.generator.generateClassName(InputStream.class, + "bytes"); + ClassName generated2 = this.generator.generateClassName(InputStream.class, + "bytes"); + ClassName generated3 = this.generator.generateClassName(InputStream.class, + "bytes"); + assertThat(generated1).hasToString("java.io.InputStream__Bytes"); + assertThat(generated2).hasToString("java.io.InputStream__Bytes1"); + assertThat(generated3).hasToString("java.io.InputStream__Bytes2"); + } + + @Test + void generateClassNameWithStringGeneratesNameUsingOnlyLetters() { + ClassName generated = this.generator.generateClassName("my-bean--factoryStuff", + "beans"); + assertThat(generated).hasToString("__.MyBeanFactoryStuff__Beans"); + } + + @Test + void generateClassNameWithStringWhenNoLettersGeneratesAotName() { + ClassName generated = this.generator.generateClassName("1234!@#", "beans"); + assertThat(generated).hasToString("__.Aot__Beans"); + } + + static class TestBean { + + } + +}