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 3fd86e1ae6..fb817f5f48 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,7 +721,9 @@ Nested members of a constructor bound class (such as `Security` in the example a Default values can be specified using `@DefaultValue` on constructor parameters and record components. The conversion service will be applied to coerce the annotation's `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`. + +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: include::code:nonnull/MyProperties[tag=*] 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 17742f72ff..8b56228242 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 @@ -409,6 +409,40 @@ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGene compile(RecursiveProperties.class); } + @Test + 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 + 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)); + } + @Test void implicitlyBoundRecordProperties(@TempDir File temp) throws IOException { File exampleRecord = new File(temp, "ExampleRecord.java"); @@ -422,6 +456,25 @@ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGene assertThat(metadata).has(Metadata.withProperty("implicit.some-integer")); } + @Test + void implicitlyBoundRecordPropertiesWithDefaultValues(@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.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)); + } + @Test void multiConstructorRecordProperties(@TempDir File temp) throws IOException { File exampleRecord = new File(temp, "ExampleRecord.java"); @@ -442,23 +495,4 @@ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGene assertThat(metadata).doesNotHave(Metadata.withProperty("multi.some-integer")); } - @Test - void recordPropertiesWithDefaultValues(@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.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 2ffd2b988e..35a59d1dae 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 @@ -222,6 +222,31 @@ class ConfigurationPropertiesBeanTests { .getBindConstructor(ConstructorBindingOnConstructor.class, false)).isNotNull(); } + @Test + void forValueObjectWithConstructorBindingAnnotatedRecordReturnsBean() { + 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 forValueObjectWithUnannotatedRecordReturnsBean() { Class implicitConstructorBinding = new ByteBuddy(ClassFileVersion.JAVA_V16).makeRecord()