From d57456361620bc20c6cd3623b7482d90013bf914 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 29 Mar 2022 14:14:28 +0100 Subject: [PATCH] Document @ConstructorBinding and @DefaultValue with records Closes gh-30460 --- .../asciidoc/features/external-config.adoc | 7 ++- .../AbstractMetadataGenerationTests.java | 10 +++- ...ationMetadataAnnotationProcessorTests.java | 46 ++++++++++++++++++- .../ConfigurationPropertiesBeanTests.java | 34 +++++++++++++- .../bind/ValueObjectBinderTests.java | 38 ++++++++++++++- 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc index d157d00a5750..6c4878a7c833 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc @@ -721,11 +721,14 @@ include::{docs-java}/features/externalconfig/typesafeconfigurationproperties/con In this setup, the `@ConstructorBinding` annotation is used to indicate that constructor binding should be used. This means that the binder will expect to find a constructor with the parameters that you wish to have bound. +If you are using Java 16 or later, constructor binding can be used with records. Nested members of a `@ConstructorBinding` class (such as `Security` in the example above) will also be bound via their constructor. -Default values can be specified using `@DefaultValue` and the same conversion service will be applied to coerce the `String` value to the target type of a missing property. -By default, if no properties are bound to `Security`, the `MyProperties` instance will contain a `null` value for `security`. +Default values can be specified using `@DefaultValue` on a constructor parameter or, when using Java 16 or later, a record component. +The conversion service will be applied to coerce the `String` value to the target type of a missing property. + +Referring to the previous example, if no properties are bound to `Security`, the `MyProperties` instance will contain a `null` value for `security`. If you wish you return a non-null instance of `Security` even when no properties are bound to it, you can use an empty `@DefaultValue` annotation to do so: [source,java,indent=0,subs="verbatim"] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/AbstractMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/AbstractMetadataGenerationTests.java index 1af2311b2a11..8cd35bd6fa1c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/AbstractMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/AbstractMetadataGenerationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -18,6 +18,7 @@ import java.io.File; import java.io.IOException; +import java.util.Arrays; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.io.TempDir; @@ -54,4 +55,11 @@ protected ConfigurationMetadata compile(Class... types) { return processor.getMetadata(); } + protected ConfigurationMetadata compile(File... sources) { + TestConfigurationMetadataAnnotationProcessor processor = new TestConfigurationMetadataAnnotationProcessor( + this.compiler.getOutputLocation()); + this.compiler.getTask(Arrays.asList(sources)).call(processor); + return processor.getMetadata(); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java index b942ee93c0c0..b4425f1edf9e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -16,7 +16,15 @@ package org.springframework.boot.configurationprocessor; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; + import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.api.io.TempDir; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; @@ -402,4 +410,40 @@ void recursivePropertiesDoNotCauseAStackOverflow() { compile(RecursiveProperties.class); } + @Test + @EnabledForJreRange(min = JRE.JAVA_16) + void explicityBoundRecordProperties(@TempDir File temp) throws IOException { + File exampleRecord = new File(temp, "ExampleRecord.java"); + try (PrintWriter writer = new PrintWriter(new FileWriter(exampleRecord))) { + writer.println("@org.springframework.boot.configurationsample.ConstructorBinding"); + writer.println("@org.springframework.boot.configurationsample.ConfigurationProperties(\"explicit\")"); + writer.println("public record ExampleRecord(String someString, Integer someInteger) {"); + writer.println("}"); + } + ConfigurationMetadata metadata = compile(exampleRecord); + assertThat(metadata).has(Metadata.withProperty("explicit.some-string")); + assertThat(metadata).has(Metadata.withProperty("explicit.some-integer")); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_16) + void explicitlyBoundRecordPropertiesWithDefaultValues(@TempDir File temp) throws IOException { + File exampleRecord = new File(temp, "ExampleRecord.java"); + try (PrintWriter writer = new PrintWriter(new FileWriter(exampleRecord))) { + writer.println("@org.springframework.boot.configurationsample.ConstructorBinding"); + writer.println( + "@org.springframework.boot.configurationsample.ConfigurationProperties(\"record.defaults\")"); + writer.println("public record ExampleRecord("); + writer.println("@org.springframework.boot.configurationsample.DefaultValue(\"An1s9n\") String someString,"); + writer.println("@org.springframework.boot.configurationsample.DefaultValue(\"594\") Integer someInteger"); + writer.println(") {"); + writer.println("}"); + } + ConfigurationMetadata metadata = compile(exampleRecord); + assertThat(metadata) + .has(Metadata.withProperty("record.defaults.some-string", String.class).withDefaultValue("An1s9n")); + assertThat(metadata) + .has(Metadata.withProperty("record.defaults.some-integer", Integer.class).withDefaultValue(594)); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java index 1530b2913917..ccd9bc6df038 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -16,9 +16,15 @@ package org.springframework.boot.context.properties; +import java.lang.reflect.Constructor; import java.util.Map; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.ClassFileVersion; +import net.bytebuddy.description.annotation.AnnotationDescription; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.function.ThrowingConsumer; import org.springframework.boot.context.properties.ConfigurationPropertiesBean.BindMethod; @@ -216,6 +222,32 @@ void forValueObjectReturnsBean() { .getBindConstructor(ConstructorBindingOnConstructor.class, false)).isNotNull(); } + @Test + @EnabledForJreRange(min = JRE.JAVA_16) + void forValueObjectWithRecordReturnsBean() { + Class constructorBindingRecord = new ByteBuddy(ClassFileVersion.JAVA_V16).makeRecord() + .name("org.springframework.boot.context.properties.RecordProperties") + .annotateType(AnnotationDescription.Builder.ofType(ConfigurationProperties.class) + .define("prefix", "explicit").build()) + .annotateType(AnnotationDescription.Builder.ofType(ConstructorBinding.class).build()) + .defineRecordComponent("someString", String.class).defineRecordComponent("someInteger", Integer.class) + .make().load(getClass().getClassLoader()).getLoaded(); + ConfigurationPropertiesBean propertiesBean = ConfigurationPropertiesBean + .forValueObject(constructorBindingRecord, "constructorBindingRecord"); + assertThat(propertiesBean.getName()).isEqualTo("constructorBindingRecord"); + assertThat(propertiesBean.getInstance()).isNull(); + assertThat(propertiesBean.getType()).isEqualTo(constructorBindingRecord); + assertThat(propertiesBean.getBindMethod()).isEqualTo(BindMethod.VALUE_OBJECT); + assertThat(propertiesBean.getAnnotation()).isNotNull(); + Bindable target = propertiesBean.asBindTarget(); + assertThat(target.getType()).isEqualTo(ResolvableType.forClass(constructorBindingRecord)); + assertThat(target.getValue()).isNull(); + Constructor bindConstructor = ConfigurationPropertiesBindConstructorProvider.INSTANCE + .getBindConstructor(constructorBindingRecord, false); + assertThat(bindConstructor).isNotNull(); + assertThat(bindConstructor.getParameterTypes()).containsExactly(String.class, Integer.class); + } + @Test void forValueObjectWhenJavaBeanBindTypeThrowsException() { assertThatIllegalStateException() diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java index 7286c7c348ec..82cb5e89f8d0 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -16,23 +16,35 @@ package org.springframework.boot.context.properties.bind; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; import java.lang.reflect.Constructor; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.api.io.TempDir; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.context.properties.source.MockConfigurationPropertySource; +import org.springframework.boot.testsupport.compiler.TestCompiler; import org.springframework.core.ResolvableType; import org.springframework.core.convert.ConversionService; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.Assert; import static org.assertj.core.api.Assertions.assertThat; @@ -357,6 +369,30 @@ void bindToAnnotationNamedParameter() { assertThat(bound.getImportName()).isEqualTo("test"); } + @Test + @EnabledForJreRange(min = JRE.JAVA_16) + void bindToRecordWithDefaultValue(@TempDir File tempDir) throws IOException, ClassNotFoundException { + MockConfigurationPropertySource source = new MockConfigurationPropertySource(); + source.put("test.record.property1", "value-from-config-1"); + this.sources.add(source); + File recordProperties = new File(tempDir, "RecordProperties.java"); + try (PrintWriter writer = new PrintWriter(new FileWriter(recordProperties))) { + writer.println("public record RecordProperties("); + writer.println( + "@org.springframework.boot.context.properties.bind.DefaultValue(\"default-value-1\") String property1,"); + writer.println( + "@org.springframework.boot.context.properties.bind.DefaultValue(\"default-value-2\") String property2"); + writer.println(") {"); + writer.println("}"); + } + TestCompiler compiler = new TestCompiler(tempDir); + compiler.getTask(Arrays.asList(recordProperties)).call(); + ClassLoader ucl = new URLClassLoader(new URL[] { tempDir.toURI().toURL() }); + Object bean = this.binder.bind("test.record", Class.forName("RecordProperties", true, ucl)).get(); + assertThat(ReflectionTestUtils.getField(bean, "property1")).isEqualTo("value-from-config-1"); + assertThat(ReflectionTestUtils.getField(bean, "property2")).isEqualTo("default-value-2"); + } + private void noConfigurationProperty(BindException ex) { assertThat(ex.getProperty()).isNull(); }