From f96111f8281995ed977c5df9359e74a2fa3b0ba2 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Tue, 13 Feb 2024 22:56:47 -0500 Subject: [PATCH] Add ValueCodeGenerator Objects and Records support Add support for generating code to create common value Objects and Records. A common value Record is a public, accessible Record class that has common value objects for all of its components. A common value Object is a public, accessible Object class that has public setters for all of its non-public instance fields, with each field being assigned a common value. The code generated for a common value Record calls the Record's canonical constructor with the generated code for each of its component. For instance, for `record MyRecord(String a, int b)`, and instance `MyRecord("Example", 1)`, it would generate the following code: new MyRecord("Example", 1) The code generated for a common value Object calls the Object's public no-args constructor and sets its fields. Public fields are set by direct access. Non-public fields are set by calling their setters. The code will be wrapped by an inlined Supplier so the generated code can be used anywhere. For example, given the following class: class MyObject { public String name; private int age; public void setAge(int age) { this.age = age; } } The code generated for MyObject("Example", 2) would be ((Supplier) () -> { MyObject $valueCodeGeneratorObject1 = new MyObject(); $valueCodeGeneratorObject1.setAge(2); $valueCodeGeneratorObject1.name = "Example"; return $valueCodeGeneratorObject1; }).get() ValueCodeGenerator uses a ThreadLocal keeping track of the number of generateCode calls in the current Thread's stack. This allows the Delegate for Object to use an unused identifier for the variable holding the Object under construction. Known limitations are that cyclic references cannot be handled. If a cyclic reference is detected, a ValueCodeGenerationException will be raised. Additionally, if the same Object is used for multiple fields/components in Objects/Records, they will be different instances instead of the same instance. This change will allow common value objects and records to be passed to the constructor of BeanDefinitions in an AOT native image. For instance, the following will now work in native image, provided `myConfig` is a common value object: MyConfig myConfig = new MyConfig(); // ...Set fields of MyConfig ... RootBeanDefinition rootBeanDefinition = new RootBeanDefinition( MyBean.class); rootBeanDefinition.setFactoryMethodName("of"); rootBeanDefinition.getConstructorArgumentValues() .addGenericArgumentValue(myConfig); registry.registerBeanDefinition(BEAN_NAME, rootBeanDefinition); --- .../BeanDefinitionMethodGeneratorTests.java | 18 ++ ...pertyValueCodeGeneratorDelegatesTests.java | 280 +++++++++++++++++- .../factory/aot/support/NestableObject.java | 66 +++++ .../factory/aot/support/NestableRecord.java | 28 ++ .../beans/factory/aot/support/ObjectPair.java | 69 +++++ .../support/ObjectWithMissingConstructor.java | 39 +++ .../aot/support/ObjectWithMissingSetter.java | 28 ++ .../support/ObjectWithPrivateConstructor.java | 38 +++ .../aot/support/ObjectWithPrivateSetter.java | 35 +++ .../factory/aot/support/ObjectWithRecord.java | 77 +++++ .../factory/aot/support/ProtectedObject.java | 27 ++ .../aot/support/ProtectedObjectCreator.java | 34 +++ .../factory/aot/support/ProtectedRecord.java | 27 ++ .../beans/factory/aot/support/RecordPair.java | 29 ++ .../factory/aot/support/RecordWithObject.java | 27 ++ .../aot/generate/ValueCodeGenerator.java | 54 +++- .../generate/ValueCodeGeneratorDelegates.java | 206 +++++++++++-- .../aot/generate/ValueCodeGeneratorTests.java | 91 ++++++ .../tests/sample/objects/TestRecord.java | 23 ++ 19 files changed, 1163 insertions(+), 33 deletions(-) create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableObject.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableRecord.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectPair.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingConstructor.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingSetter.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateConstructor.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateSetter.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithRecord.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObject.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObjectCreator.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedRecord.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordPair.java create mode 100644 spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordWithObject.java create mode 100644 spring-core/src/test/java/org/springframework/tests/sample/objects/TestRecord.java diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorTests.java index 991578a2fca4..d7cb6da2aa4b 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionMethodGeneratorTests.java @@ -34,6 +34,9 @@ import org.springframework.aot.generate.MethodReference; import org.springframework.aot.generate.MethodReference.ArgumentCodeGenerator; import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.factory.aot.support.ObjectPair; +import org.springframework.beans.factory.aot.support.RecordPair; +import org.springframework.beans.factory.aot.support.RecordWithObject; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder; import org.springframework.beans.factory.support.BeanDefinitionBuilder; @@ -683,6 +686,21 @@ void generateBeanDefinitionMethodWhenBeanIsOfPrimitiveType() { testBeanDefinitionMethodInCurrentFile(Boolean.class, beanDefinition); } + @Test + void generateBeanDefinitionMethodWhenBeanIsOfCommonValueType() { + RootBeanDefinition beanDefinition = (RootBeanDefinition) BeanDefinitionBuilder + .rootBeanDefinition(RecordPair.class) + .setFactoryMethod("of") + .addConstructorArgValue(new RecordWithObject(0, null)) + .addConstructorArgValue(new ObjectPair<>("Test", 0)) + .getBeanDefinition(); + BeanDefinitionMethodGenerator generator = new BeanDefinitionMethodGenerator( + this.methodGeneratorFactory, registerBean(beanDefinition), null, Collections.emptyList()); + MethodReference method = generator.generateBeanDefinitionMethod( + this.generationContext, this.beanRegistrationsCode); + compile(method, (actual, compiled) -> assertThat(actual).isEqualTo(beanDefinition)); + } + @Test void generateBeanDefinitionMethodWhenInstanceSupplierWithNoCustomization() { RegisteredBean registeredBean = registerBean(new RootBeanDefinition(TestBean.class, TestBean::new)); diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegatesTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegatesTests.java index 0dafc56c1a23..2f01ccaf6da5 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegatesTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/BeanDefinitionPropertyValueCodeGeneratorDelegatesTests.java @@ -18,10 +18,13 @@ import java.io.InputStream; import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -36,9 +39,21 @@ import org.junit.jupiter.api.Test; import org.springframework.aot.generate.GeneratedClass; +import org.springframework.aot.generate.ValueCodeGenerationException; import org.springframework.aot.generate.ValueCodeGenerator; import org.springframework.aot.generate.ValueCodeGeneratorDelegates; import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.factory.aot.support.NestableObject; +import org.springframework.beans.factory.aot.support.NestableRecord; +import org.springframework.beans.factory.aot.support.ObjectPair; +import org.springframework.beans.factory.aot.support.ObjectWithMissingConstructor; +import org.springframework.beans.factory.aot.support.ObjectWithMissingSetter; +import org.springframework.beans.factory.aot.support.ObjectWithPrivateConstructor; +import org.springframework.beans.factory.aot.support.ObjectWithPrivateSetter; +import org.springframework.beans.factory.aot.support.ObjectWithRecord; +import org.springframework.beans.factory.aot.support.ProtectedObjectCreator; +import org.springframework.beans.factory.aot.support.RecordPair; +import org.springframework.beans.factory.aot.support.RecordWithObject; import org.springframework.beans.factory.config.BeanReference; import org.springframework.beans.factory.config.RuntimeBeanNameReference; import org.springframework.beans.factory.config.RuntimeBeanReference; @@ -55,8 +70,10 @@ import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.MethodSpec; import org.springframework.javapoet.ParameterizedTypeName; +import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; /** * Tests for {@link BeanDefinitionPropertyValueCodeGeneratorDelegates}. This @@ -295,6 +312,15 @@ void generateWhenClassArray() { assertThat(instance).isEqualTo(classes)); } + @Test + void noGenerateWhenSelfReference() { + Object[] items = new Object[1]; + items[0] = items; + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(items)) + .isInstanceOf(ValueCodeGenerationException.class); + } + } @Nested @@ -317,6 +343,15 @@ void generateWhenEmptyManagedList() { .isInstanceOf(ManagedList.class)); } + @Test + void noGenerateWhenSelfReference() { + ManagedList list = new ManagedList<>(); + list.add(list); + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(list)) + .isInstanceOf(ValueCodeGenerationException.class); + } + } @Nested @@ -338,7 +373,6 @@ void generateWhenEmptyManagedSet() { compile(set, (instance, compiler) -> assertThat(instance).isEqualTo(set) .isInstanceOf(ManagedSet.class)); } - } @Nested @@ -359,7 +393,6 @@ void generateWhenEmptyManagedMap() { compile(map, (instance, compiler) -> assertThat(instance).isEqualTo(map) .isInstanceOf(ManagedMap.class)); } - } @Nested @@ -378,6 +411,15 @@ void generateWhenEmptyList() { compile(list, (instance, compiler) -> assertThat(instance).isEqualTo(list)); } + @Test + void noGenerateWhenSelfReference() { + List list = new ArrayList<>(); + list.add(list); + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(list)) + .isInstanceOf(ValueCodeGenerationException.class); + } + } @Nested @@ -408,7 +450,6 @@ void generateWhenSetOfClass() { Set> set = Set.of(String.class, Integer.class, Long.class); compile(set, (instance, compiler) -> assertThat(instance).isEqualTo(set)); } - } @Nested @@ -442,6 +483,237 @@ void generateWhenLinkedHashMap() { }); } + @Test + void noGenerateWhenSelfReference() { + Map map = new IdentityHashMap<>(); + map.put(map, map); + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(map)) + .isInstanceOf(ValueCodeGenerationException.class); + } + + } + + @Nested + class RecordTests { + @Test + void generatedPublicRecord() { + NestableRecord record = new NestableRecord(0, "record", null); + compile(record, (instance, compiler) -> { + NestableRecord actual = (NestableRecord) instance; + assertThat(actual).isEqualTo(record); + }); + } + + @Test + void generatedNestedPublicRecord() { + NestableRecord record = new NestableRecord(0, "parent", new NestableRecord(1, "child", null)); + compile(record, (instance, compiler) -> { + NestableRecord actual = (NestableRecord) instance; + assertThat(actual).isEqualTo(record); + }); + } + + @Test + void generatedNestedWithObjects() { + RecordWithObject record = new RecordWithObject(0, new ObjectWithRecord(1, new RecordWithObject(2, new ObjectWithRecord(3, null)))); + compile(record, (instance, compiler) -> { + RecordWithObject actual = (RecordWithObject) instance; + assertThat(actual).isEqualTo(record); + }); + } + + @Test + void generatedGenericRecord() { + RecordPair pair = new RecordPair<>("Left", 1); + compile(pair, (instance, compiler) -> { + RecordPair actual = (RecordPair) instance; + assertThat(actual).isEqualTo(pair); + }); + } + + @Test + void notGeneratedCyclicRecord() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + ObjectWithRecord cycleEnd = new ObjectWithRecord(1); + RecordWithObject cycleStart = new RecordWithObject(0, cycleEnd); + cycleEnd.setNested(cycleStart); + assertThatCode(() -> valueCodeGenerator.generateCode(cycleStart)) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedProtectedRecord() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(ProtectedObjectCreator.getProtectedRecord())) + .isInstanceOf(ValueCodeGenerationException.class); + } + + public record InaccessibleRecord() {} + + @Test + void notGeneratedPublicRecordInProtectedClass() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(new InaccessibleRecord())) + .isInstanceOf(ValueCodeGenerationException.class); + } + } + + @Nested + class ObjectTests { + @Test + void generatedPublicObject() { + NestableObject object = new NestableObject(); + object.name = "Object"; + object.setId(1); + object.setNested(null); + compile(object, (instance, compiler) -> { + NestableObject actual = (NestableObject) instance; + assertThat(actual).isEqualTo(object); + }); + } + + @Test + void generatedNestedPublicObject() { + NestableObject parent = new NestableObject(); + parent.name = "Parent"; + parent.setId(1); + NestableObject child = new NestableObject(); + child.name = "Child"; + child.setId(2); + child.setNested(null); + parent.setNested(child); + compile(parent, (instance, compiler) -> { + NestableObject actual = (NestableObject) instance; + assertThat(actual).isEqualTo(parent); + }); + } + + @Test + void generatedGeneric() { + ObjectPair pair = new ObjectPair<>("Test", 1); + compile(pair, (instance, compiler) -> { + ObjectPair actual = (ObjectPair) instance; + assertThat(actual).isEqualTo(pair); + }); + } + + @Test + void generatedPairOfObjects() { + ObjectPair, ObjectPair> pair = + new ObjectPair<>(new ObjectPair<>("Left", 0), + new ObjectPair<>(1, "Right")); + compile(pair, (instance, compiler) -> { + ObjectPair actual = (ObjectPair) instance; + assertThat(actual).isEqualTo(pair); + }); + } + + @Test + void generatedNestedWithRecords() { + ObjectWithRecord object = new ObjectWithRecord(0, new RecordWithObject(1, new ObjectWithRecord(2, new RecordWithObject(3, null)))); + compile(object, (instance, compiler) -> { + ObjectWithRecord actual = (ObjectWithRecord) instance; + assertThat(actual).isEqualTo(object); + }); + } + + @Test + void generatedPairOfObjectsWithinList() { + var pair = new ObjectPair<>(List.of(new ObjectPair<>("Left", 0)), + List.of(new ObjectPair<>(1, "Right"))); + compile(pair, (instance, compiler) -> { + ObjectPair actual = (ObjectPair) instance; + assertThat(actual).isEqualTo(pair); + }); + } + + @Test + void generatedPairOfObjectsWithinSet() { + var pair = new ObjectPair<>(Set.of(new ObjectPair<>("Left", 0)), + Set.of(new ObjectPair<>(1, "Right"))); + compile(pair, (instance, compiler) -> { + ObjectPair actual = (ObjectPair) instance; + assertThat(actual).isEqualTo(pair); + }); + } + + @Test + void generatedPairOfObjectsWithinMap() { + var pair = new ObjectPair<>(Map.of("a", new ObjectPair<>("Left", 0)), + Map.of("b", new ObjectPair<>(1, "Right"))); + compile(pair, (instance, compiler) -> { + ObjectPair actual = (ObjectPair) instance; + assertThat(actual).isEqualTo(pair); + }); + } + + @Test + void notGeneratedProtectedClass() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(ProtectedObjectCreator.getProtectedObject())) + .isInstanceOf(ValueCodeGenerationException.class); + } + + public static class InaccessibleObject {} + + @Test + void notGeneratedInaccessibleClass() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(new InaccessibleObject())) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedMissingConstructor() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(new ObjectWithMissingConstructor("value"))) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedPrivateConstructor() + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + ObjectWithPrivateConstructor instance = ReflectionUtils.accessibleConstructor(ObjectWithPrivateConstructor.class).newInstance(); + assertThatCode(() -> valueCodeGenerator.generateCode(instance)) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedMissingSetter() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(new ObjectWithMissingSetter())) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedPrivateSetter() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(new ObjectWithPrivateSetter())) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedCyclicPublicObject() { + NestableObject cyclicObject = new NestableObject(); + cyclicObject.name = "Cyclic"; + cyclicObject.setId(0); + cyclicObject.setNested(cyclicObject); + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + assertThatCode(() -> valueCodeGenerator.generateCode(cyclicObject)) + .isInstanceOf(ValueCodeGenerationException.class); + } + + @Test + void notGeneratedCyclicObjectFromRecord() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + ObjectWithRecord cycleEnd = new ObjectWithRecord(1); + RecordWithObject cycleStart = new RecordWithObject(0, cycleEnd); + cycleEnd.setNested(cycleStart); + assertThatCode(() -> valueCodeGenerator.generateCode(cycleEnd)) + .isInstanceOf(ValueCodeGenerationException.class); + } } @Nested @@ -474,7 +746,5 @@ void generatedWhenBeanReferenceByType() { assertThat(actual.getBeanType()).isEqualTo(String.class); }); } - } - } diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableObject.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableObject.java new file mode 100644 index 000000000000..16bac9a5b5b5 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableObject.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +import java.util.Objects; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with nestable objects. + * + * @author Christopher Chianelli + */ +public class NestableObject { + private static final String A_STATIC_FIELD = "STATIC_FIELD"; + + public String name; + private int id; + private NestableObject nested; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public NestableObject getNested() { + return nested; + } + + public void setNested(NestableObject nested) { + this.nested = nested; + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; + NestableObject that = (NestableObject) object; + return id == that.id + && Objects.equals(name, that.name) + && Objects.equals(nested, that.nested); + } + + @Override + public int hashCode() { + return Objects.hash(name, id); + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableRecord.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableRecord.java new file mode 100644 index 000000000000..5cfea3253c29 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/NestableRecord.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +import org.springframework.lang.Nullable; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with nestable records. + * + * @author Christopher Chianelli + */ +public record NestableRecord(int id, String name, @Nullable NestableRecord nested) { +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectPair.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectPair.java new file mode 100644 index 000000000000..c0cdba47aad3 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectPair.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +import java.util.Objects; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with generic object pairs. + * + * @author Christopher Chianelli + */ +public class ObjectPair { + L left; + R right; + + public ObjectPair() { + } + + public ObjectPair(L left, R right) { + this.left = left; + this.right = right; + } + + public L getLeft() { + return left; + } + + public void setLeft(L left) { + this.left = left; + } + + public R getRight() { + return right; + } + + public void setRight(R right) { + this.right = right; + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; + ObjectPair that = (ObjectPair) object; + return Objects.equals(left, that.left) && Objects.equals(right, that.right); + } + + @Override + public int hashCode() { + return Objects.hash(left, right); + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingConstructor.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingConstructor.java new file mode 100644 index 000000000000..1e1dc0ad7ef6 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingConstructor.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with objects without a no-args constructor. + * + * @author Christopher Chianelli + */ +public class ObjectWithMissingConstructor { + private String field; + + public ObjectWithMissingConstructor(String field) { + this.field = field; + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingSetter.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingSetter.java new file mode 100644 index 000000000000..22bf7bee7f6a --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithMissingSetter.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with objects missing a setter. + * + * @author Christopher Chianelli + */ +public class ObjectWithMissingSetter { + private static final String A_STATIC_FIELD = "STATIC_FIELD"; + private String field; +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateConstructor.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateConstructor.java new file mode 100644 index 000000000000..3649124aeb9b --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateConstructor.java @@ -0,0 +1,38 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with objects that have a private constructor. + * + * @author Christopher Chianelli + */ +public class ObjectWithPrivateConstructor { + private String field; + + private ObjectWithPrivateConstructor() { + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateSetter.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateSetter.java new file mode 100644 index 000000000000..731ebb3a5fe5 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithPrivateSetter.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with objects that have a private setter. + * + * @author Christopher Chianelli + */ +public class ObjectWithPrivateSetter { + private String field; + + private String getField() { + return field; + } + + private void setField(String field) { + this.field = field; + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithRecord.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithRecord.java new file mode 100644 index 000000000000..e1e45f8d3c13 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ObjectWithRecord.java @@ -0,0 +1,77 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +import java.util.Objects; + +import org.springframework.lang.Nullable; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with objects containing a record. + * + * @author Christopher Chianelli + */ +public class ObjectWithRecord { + private int id; + @Nullable + private RecordWithObject nested; + + public ObjectWithRecord() { + } + + public ObjectWithRecord(int id) { + this(id, null); + } + + public ObjectWithRecord(int id, @Nullable RecordWithObject nested) { + this.id = id; + this.nested = nested; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + @Nullable + public RecordWithObject getNested() { + return nested; + } + + public void setNested(@Nullable RecordWithObject nested) { + this.nested = nested; + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; + ObjectWithRecord that = (ObjectWithRecord) object; + return id == that.id && Objects.equals(nested, that.nested); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObject.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObject.java new file mode 100644 index 000000000000..e8d7b930688b --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObject.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with objects whose class is protected + * + * @author Christopher Chianelli + */ +class ProtectedObject { + public ProtectedObject() {} +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObjectCreator.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObjectCreator.java new file mode 100644 index 000000000000..875e727be93b --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedObjectCreator.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Class for generating test objects and records that are protected. + * + * @author Christopher Chianelli + */ +public class ProtectedObjectCreator { + private ProtectedObjectCreator() {} + + public static Object getProtectedRecord() { + return new ProtectedRecord(); + } + + public static Object getProtectedObject() { + return new ProtectedObject(); + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedRecord.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedRecord.java new file mode 100644 index 000000000000..40591f656782 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/ProtectedRecord.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with protected records. + * + * @author Christopher Chianelli + */ +record ProtectedRecord() { + public ProtectedRecord() {} +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordPair.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordPair.java new file mode 100644 index 000000000000..3fb65685bfa9 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordPair.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with generic records. + * + * @author Christopher Chianelli + */ +public record RecordPair(Left_ left, Right_ right) { + public static RecordPair of(Left_ left, Right_ right) { + return new RecordPair<>(left, right); + } +} diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordWithObject.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordWithObject.java new file mode 100644 index 000000000000..2ba195345832 --- /dev/null +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/support/RecordWithObject.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2024 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.beans.factory.aot.support; + +import org.springframework.lang.Nullable; + +/** + * Test class for testing {@link org.springframework.aot.generate.ValueCodeGenerator} + * with nested records. + * + * @author Christopher Chianelli + */ +public record RecordWithObject(int id, @Nullable ObjectWithRecord record) {} diff --git a/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGenerator.java b/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGenerator.java index 61008f5aa259..d1792121a43c 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGenerator.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGenerator.java @@ -18,7 +18,10 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Set; import org.springframework.javapoet.CodeBlock; import org.springframework.lang.Nullable; @@ -32,8 +35,9 @@ * @since 6.1.2 */ public final class ValueCodeGenerator { + static final String DEFAULT_IDENTIFIER_PREFIX = "$valueCodeGeneratorObject"; - private static final ValueCodeGenerator INSTANCE = new ValueCodeGenerator(ValueCodeGeneratorDelegates.INSTANCES, null); + private static final ValueCodeGenerator INSTANCE = new ValueCodeGenerator(DEFAULT_IDENTIFIER_PREFIX, ValueCodeGeneratorDelegates.INSTANCES, null); private static final CodeBlock NULL_VALUE_CODE_BLOCK = CodeBlock.of("null"); @@ -42,7 +46,13 @@ public final class ValueCodeGenerator { @Nullable private final GeneratedMethods generatedMethods; - private ValueCodeGenerator(List delegates, @Nullable GeneratedMethods generatedMethods) { + private final ThreadLocal generatedCodeDepth = ThreadLocal.withInitial(() -> 0); + private final ThreadLocal> toGenerateObjectSet = ThreadLocal.withInitial(() -> Collections.newSetFromMap(new IdentityHashMap<>())); + + private final String identifierPrefix; + + private ValueCodeGenerator(String identifierPrefix, List delegates, @Nullable GeneratedMethods generatedMethods) { + this.identifierPrefix = identifierPrefix; this.delegates = delegates; this.generatedMethods = generatedMethods; } @@ -72,14 +82,27 @@ public static ValueCodeGenerator with(Delegate... delegates) { */ public static ValueCodeGenerator with(List delegates) { Assert.notEmpty(delegates, "Delegates must not be empty"); - return new ValueCodeGenerator(new ArrayList<>(delegates), null); + return new ValueCodeGenerator(DEFAULT_IDENTIFIER_PREFIX, new ArrayList<>(delegates), null); } public ValueCodeGenerator add(List additionalDelegates) { Assert.notEmpty(additionalDelegates, "AdditionalDelegates must not be empty"); List allDelegates = new ArrayList<>(this.delegates); allDelegates.addAll(additionalDelegates); - return new ValueCodeGenerator(allDelegates, this.generatedMethods); + return new ValueCodeGenerator(DEFAULT_IDENTIFIER_PREFIX, allDelegates, this.generatedMethods); + } + + /** + * Return a new {@link ValueCodeGenerator} that uses the given {@link String} as the + * prefix for all of its identifiers. + * @param identifierPrefix the prefix to use for each identifier. Cannot be null or empty. + * @return a new {@link ValueCodeGenerator} with the same delegates and generated methods + * as this {@link ValueCodeGenerator}. + */ + public ValueCodeGenerator withIdentifierPrefix(String identifierPrefix) { + Assert.notNull(identifierPrefix, "Identifier prefix must not be null"); + Assert.state(!identifierPrefix.isEmpty(), "Identifier prefix must not be empty"); + return new ValueCodeGenerator(identifierPrefix, this.delegates, this.generatedMethods); } /** @@ -91,7 +114,7 @@ public ValueCodeGenerator add(List additionalDelegates) { * @return an instance scoped to the specified generated methods */ public ValueCodeGenerator scoped(GeneratedMethods generatedMethods) { - return new ValueCodeGenerator(this.delegates, generatedMethods); + return new ValueCodeGenerator(DEFAULT_IDENTIFIER_PREFIX, this.delegates, generatedMethods); } /** @@ -103,7 +126,13 @@ public CodeBlock generateCode(@Nullable Object value) { if (value == null) { return NULL_VALUE_CODE_BLOCK; } + if (this.toGenerateObjectSet.get().contains(value)) { + throw new ValueCodeGenerationException("Unable to generate code for (" + value + ") because it contains a cyclic reference.", + value, null); + } try { + this.toGenerateObjectSet.get().add(value); + this.generatedCodeDepth.set(this.generatedCodeDepth.get() + 1); for (Delegate delegate : this.delegates) { CodeBlock code = delegate.generateCode(this, value); if (code != null) { @@ -115,8 +144,23 @@ public CodeBlock generateCode(@Nullable Object value) { catch (Exception ex) { throw new ValueCodeGenerationException(value, ex); } + finally { + this.generatedCodeDepth.set(this.generatedCodeDepth.get() - 1); + this.toGenerateObjectSet.get().remove(value); + } } + /** + * Returns a String that can be used as a new identifier in + * a {@link CodeBlock} expression. + * For a given {@link Delegate} call, it will always return the same value. + * That is, each {@link Delegate} can define at most one new variable. + * @return an identifier that can be used in a {@link CodeBlock} that + * is guaranteed to not be used in an upper {@link Delegate} call. + */ + public String getIdentifierForCurrentDepth() { + return this.identifierPrefix + this.generatedCodeDepth.get(); + } /** * Return the {@link GeneratedMethods} that represents the scope diff --git a/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGeneratorDelegates.java b/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGeneratorDelegates.java index ce4ade2027b6..feb289984f3e 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGeneratorDelegates.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/ValueCodeGeneratorDelegates.java @@ -16,10 +16,18 @@ package org.springframework.aot.generate; +import java.lang.reflect.Field; +import java.lang.reflect.InaccessibleObjectException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -28,6 +36,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.function.Supplier; import java.util.stream.Stream; import org.springframework.aot.generate.ValueCodeGenerator.Delegate; @@ -37,6 +46,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; /** * Code generator {@link Delegate} for well known value types. @@ -59,7 +69,9 @@ public abstract class ValueCodeGeneratorDelegates { *
  • Array,
  • *
  • List via {@code List.of},
  • *
  • Set via {@code Set.of} and support of {@link LinkedHashSet},
  • - *
  • Map via {@code Map.of} or {@code Map.ofEntries}.
  • + *
  • Map via {@code Map.of} or {@code Map.ofEntries},
  • + *
  • Records containing common value types for all its {@link RecordComponent components},
  • + *
  • Objects with public setters for all non-public fields, with the value of each field being a common value type.
  • * * Those implementations do not require the {@link ValueCodeGenerator} to be * {@linkplain ValueCodeGenerator#scoped(GeneratedMethods) scoped}. @@ -74,8 +86,21 @@ public abstract class ValueCodeGeneratorDelegates { new ArrayDelegate(), new ListDelegate(), new SetDelegate(), - new MapDelegate()); - + new MapDelegate(), + new RecordDelegate(), + new ObjectDelegate() + ); + + private static boolean isAccessible(Class type) { + Class currentType = type; + while (currentType != null) { + if (!Modifier.isPublic(currentType.getModifiers())) { + return false; + } + currentType = currentType.getDeclaringClass(); + } + return true; + } /** * Abstract {@link Delegate} for {@code Collection} types. @@ -126,7 +151,6 @@ protected final CodeBlock generateCollectionOf(ValueCodeGenerator valueCodeGener } } - /** * {@link Delegate} for {@link Map} types. */ @@ -188,7 +212,6 @@ private Map orderForCodeConsistency(Map map) { } } - /** * {@link Delegate} for {@code primitive} types. */ @@ -243,7 +266,6 @@ private String escape(char ch) { } } - /** * {@link Delegate} for {@link String} types. */ @@ -259,7 +281,6 @@ public CodeBlock generateCode(ValueCodeGenerator codeGenerator, Object value) { } } - /** * {@link Delegate} for {@link Charset} types. */ @@ -275,7 +296,6 @@ public CodeBlock generateCode(ValueCodeGenerator codeGenerator, Object value) { } } - /** * {@link Delegate} for {@link Enum} types. */ @@ -292,7 +312,6 @@ public CodeBlock generateCode(ValueCodeGenerator codeGenerator, Object value) { } } - /** * {@link Delegate} for {@link Class} types. */ @@ -308,22 +327,11 @@ public CodeBlock generateCode(ValueCodeGenerator codeGenerator, Object value) { } } - /** * {@link Delegate} for {@link ResolvableType} types. */ private static class ResolvableTypeDelegate implements Delegate { - @Override - @Nullable - public CodeBlock generateCode(ValueCodeGenerator codeGenerator, Object value) { - if (value instanceof ResolvableType resolvableType) { - return generateCode(resolvableType, false); - } - return null; - } - - private static CodeBlock generateCode(ResolvableType resolvableType, boolean allowClassResult) { if (ResolvableType.NONE.equals(resolvableType)) { return CodeBlock.of("$T.NONE", ResolvableType.class); @@ -349,8 +357,16 @@ private static CodeBlock generateCodeWithGenerics(ResolvableType target, Class> { } } - /** * {@link Delegate} for {@link Set} types. */ @@ -415,4 +429,150 @@ private Set orderForCodeConsistency(Set set) { } } + /** + * {@link Delegate} for accessible public {@link Record}. + */ + private static class RecordDelegate implements Delegate { + @Override + public CodeBlock generateCode(ValueCodeGenerator valueCodeGenerator, Object value) { + if (!(value instanceof Record record) || !isAccessible(record.getClass())) { + return null; + } + // Guaranteed to be in the same order as the canonical constructor + RecordComponent[] recordComponents = record.getClass().getRecordComponents(); + // A public record is guaranteed to have a public constructor taking all its components + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add("new $T(", record.getClass()); + for (int i = 0; i < recordComponents.length; i++) { + if (i != 0) { + builder.add(", "); + } + Object componentValue; + try { + componentValue = recordComponents[i].getAccessor().invoke(record); + } + catch (IllegalAccessException | InvocationTargetException ex) { + throw new ValueCodeGenerationException("Unable to generate code for value (" + record + ") since its component (" + recordComponents[i].getName() + ") could not be read.", record, ex); + } + builder.add(valueCodeGenerator.generateCode(componentValue)); + } + builder.add(")"); + return builder.build(); + } + } + + /** + * {@link Delegate} for accessible public {@link Record} and {@link Object} types with a public no-args constructor and + * public setters for all non-public fields. + */ + private static class ObjectDelegate implements Delegate { + private static boolean isNotObjectCommonValueType(Object value) { + return getGeneratedCodeType(value.getClass()) != value.getClass() + || value instanceof Record + || ClassUtils.isSimpleValueType(value.getClass()) + || value.getClass().isArray(); + } + + @Override + public CodeBlock generateCode(ValueCodeGenerator valueCodeGenerator, Object value) { + if (isNotObjectCommonValueType(value) || !isAccessible(value.getClass())) { + // Use a different delegate to generate the code + return null; + } + try { + CodeBlock.Builder builder = CodeBlock.builder(); + + // A trick to get a code block where we can generate statements + // anywhere in an expression (i.e. we can put the generated code as an argument + // to a method): create a supplier lambda and immediately call its + // "get" method. + // We need to cast the lambda to Supplier, so Java will + // know the type of the lambda + // String tryMe = ((Supplier) () -> { return "hi"; }).get(); + builder.add("(($T<$T>) () -> {$>\n", Supplier.class, value.getClass()); + + String identifier = valueCodeGenerator.getIdentifierForCurrentDepth(); + + generateCodeToSetFields(valueCodeGenerator, builder, identifier, + value); + + builder.add("return $N;", identifier); + builder.add("$<\n}).get()"); + return builder.build(); + } + catch (ValueCodeGenerationException ex) { + // If we fail to generate code, return null, since a different Delegate + // might be able to handle this Object + return null; + } + } + + private static Class getGeneratedCodeType(Class type) { + if (Set.class.isAssignableFrom(type)) { + return Set.class; + } + if (List.class.isAssignableFrom(type)) { + return List.class; + } + if (Map.class.isAssignableFrom(type)) { + return Map.class; + } + return type; + } + + private static void generateCodeToSetFields(ValueCodeGenerator valueCodeGenerator, + CodeBlock.Builder builder, + String identifier, + Object value) { + try { + // A Constructor is returned only if it is public; a private no-args + // Constructor will also raise a NoSuchMethodException + value.getClass().getConstructor(); + } + catch (NoSuchMethodException ex) { + throw new ValueCodeGenerationException("Unable to generate code for value (" + value + ") since its class does not have an accessible no-args constructor.", value, ex); + } + builder.add("$T $N = new $T();\n", value.getClass(), identifier, value.getClass()); + // Sort the object's fields, so we have a consistent ordering + List objectFields = new ArrayList<>(); + ReflectionUtils.doWithFields(value.getClass(), objectFields::add); + objectFields.sort(Comparator.comparing(Field::getName)); + + for (Field field : objectFields) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + Object fieldValue; + try { + // Set the field to accessible so we can read it + field.setAccessible(true); + // Use Field::get instead of ReflectionUtils::getField, + // since we want to throw a ValueCodeGenerationException + // if the field cannot be read for any reason + fieldValue = field.get(value); + } + catch (InaccessibleObjectException | IllegalAccessException | SecurityException ex) { + throw new ValueCodeGenerationException("Unable to generate code for value (" + value + ") since its field (" + field + ") cannot be read.", + value, ex); + } + if (Modifier.isPublic(field.getModifiers())) { + builder.add("$N.$N = $L;\n", identifier, field.getName(), + valueCodeGenerator.generateCode(fieldValue)); + continue; + } + String methodSuffix = Character.toUpperCase(field.getName().charAt(0)) + field.getName().substring(1); + String setterMethodName = "set" + methodSuffix; + Class expectedParameterType = getGeneratedCodeType(field.getType()); + Method setterMethod = ReflectionUtils.findMethod(value.getClass(), setterMethodName, expectedParameterType); + if (setterMethod == null + || !Modifier.isPublic(setterMethod.getModifiers()) + || Modifier.isStatic(setterMethod.getModifiers())) { + throw new ValueCodeGenerationException("Unable to generate code for value (" + value + ") since its field (" + field + ") is not public and does not have a public setter.", + value, null); + } + builder.add("$N.$N($L);\n", identifier, setterMethodName, + valueCodeGenerator.generateCode(fieldValue)); + } + } + } } diff --git a/spring-core/src/test/java/org/springframework/aot/generate/ValueCodeGeneratorTests.java b/spring-core/src/test/java/org/springframework/aot/generate/ValueCodeGeneratorTests.java index dced0ed7bbf1..751da00ce936 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/ValueCodeGeneratorTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/ValueCodeGeneratorTests.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import org.assertj.core.api.AbstractAssert; import org.assertj.core.api.AssertProvider; @@ -48,6 +49,8 @@ import org.springframework.javapoet.JavaFile; import org.springframework.javapoet.TypeSpec; import org.springframework.lang.Nullable; +import org.springframework.tests.sample.objects.TestObject; +import org.springframework.tests.sample.objects.TestRecord; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -112,6 +115,16 @@ void scopedReturnsImmutableCopy() { assertThat(valueCodeGenerator.getGeneratedMethods()).isNull(); } + @Test + void withIdentifierPrefixReturnsImmutableCopy() { + ValueCodeGenerator valueCodeGenerator = ValueCodeGenerator.withDefaults(); + ValueCodeGenerator withIdentifierPrefixValueCodeGenerator = valueCodeGenerator.withIdentifierPrefix("myPrefix"); + assertThat(withIdentifierPrefixValueCodeGenerator).isNotSameAs(valueCodeGenerator); + assertThat(withIdentifierPrefixValueCodeGenerator.getIdentifierForCurrentDepth()).isEqualTo("myPrefix0"); + assertThat(valueCodeGenerator.getIdentifierForCurrentDepth()).isEqualTo(ValueCodeGenerator.DEFAULT_IDENTIFIER_PREFIX + "0"); + assertThat(valueCodeGenerator.getGeneratedMethods()).isNull(); + } + } @Nested @@ -390,6 +403,84 @@ void generateWhenMapWithOverTenElements() { } + @Nested + class ObjectTests { + @Test + void generateTestObject() { + TestObject testObject = new TestObject("Bob", 12); + assertThat(generateCode(testObject).toString()) + .isEqualTo(CodeBlock.of("(($T<$T>) () -> {$>\n" + + "$T $$valueCodeGeneratorObject1 = new $T();\n" + + "$$valueCodeGeneratorObject1.setAge(12);\n" + + "$$valueCodeGeneratorObject1.setName(\"Bob\");\n" + + "$$valueCodeGeneratorObject1.setSpouse(null);\n" + + "return $$valueCodeGeneratorObject1;$<\n" + + "}).get()", + Supplier.class, TestObject.class, TestObject.class, TestObject.class) + .toString()); + } + + @Test + void generateNestedTestObject() { + TestObject spouse = new TestObject("Ann", 13); + TestObject testObject = new TestObject("Bob", 12); + testObject.setSpouse(spouse); + assertThat(generateCode(testObject).toString()) + .isEqualTo(CodeBlock.of("(($T<$T>) () -> {$>\n" + + "$T $$valueCodeGeneratorObject1 = new $T();\n" + + "$$valueCodeGeneratorObject1.setAge(12);\n" + + "$$valueCodeGeneratorObject1.setName(\"Bob\");\n" + + "$$valueCodeGeneratorObject1.setSpouse((($T<$T>) () -> {$>\n" + + "$T $$valueCodeGeneratorObject2 = new $T();\n" + + "$$valueCodeGeneratorObject2.setAge(13);\n" + + "$$valueCodeGeneratorObject2.setName(\"Ann\");\n" + + "$$valueCodeGeneratorObject2.setSpouse(null);\n" + + "return $$valueCodeGeneratorObject2;$<\n" + + "}).get());\n" + + "return $$valueCodeGeneratorObject1;$<\n" + + "}).get()", + Supplier.class, TestObject.class, TestObject.class, TestObject.class, + Supplier.class, TestObject.class, TestObject.class, TestObject.class) + .toString()); + } + + @Test + void generateTestObjectWithDifferentIdentifierPrefix() { + TestObject testObject = new TestObject("Bob", 12); + assertThat(ValueCodeGenerator.withDefaults() + .withIdentifierPrefix("testObject") + .generateCode(testObject).toString()) + .isEqualTo(CodeBlock.of("(($T<$T>) () -> {$>\n" + + "$T testObject1 = new $T();\n" + + "testObject1.setAge(12);\n" + + "testObject1.setName(\"Bob\");\n" + + "testObject1.setSpouse(null);\n" + + "return testObject1;$<\n" + + "}).get()", + Supplier.class, TestObject.class, TestObject.class, TestObject.class) + .toString()); + } + } + + @Nested + class RecordTests { + @Test + void generateTestRecord() { + TestRecord testRecord = new TestRecord("Bob", 12); + assertThat(resolve(generateCode(testRecord))) + .hasImport(TestRecord.class) + .hasValueCode("new TestRecord(\"Bob\", 12, null)"); + } + + @Test + void generateNestedTestRecord() { + TestRecord testRecord = new TestRecord("Bob", 12, new TestRecord("Ann", 13)); + assertThat(resolve(generateCode(testRecord))) + .hasImport(TestRecord.class) + .hasValueCode("new TestRecord(\"Bob\", 12, new TestRecord(\"Ann\", 13, null))"); + } + } + @Nested class ExceptionTests { diff --git a/spring-core/src/test/java/org/springframework/tests/sample/objects/TestRecord.java b/spring-core/src/test/java/org/springframework/tests/sample/objects/TestRecord.java new file mode 100644 index 000000000000..6b66644a2ff7 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/tests/sample/objects/TestRecord.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024-2024 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.tests.sample.objects; + +public record TestRecord(String name, int age, TestRecord spouse) { + public TestRecord(String name, int age) { + this(name, age, null); + } +}