Skip to content

Commit d574563

Browse files
committed
Document @ConstructorBinding and @DefaultValue with records
Closes gh-30460
1 parent 24e748d commit d574563

File tree

5 files changed

+129
-6
lines changed

5 files changed

+129
-6
lines changed

spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,11 +721,14 @@ include::{docs-java}/features/externalconfig/typesafeconfigurationproperties/con
721721

722722
In this setup, the `@ConstructorBinding` annotation is used to indicate that constructor binding should be used.
723723
This means that the binder will expect to find a constructor with the parameters that you wish to have bound.
724+
If you are using Java 16 or later, constructor binding can be used with records.
724725

725726
Nested members of a `@ConstructorBinding` class (such as `Security` in the example above) will also be bound via their constructor.
726727

727-
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.
728-
By default, if no properties are bound to `Security`, the `MyProperties` instance will contain a `null` value for `security`.
728+
Default values can be specified using `@DefaultValue` on a constructor parameter or, when using Java 16 or later, a record component.
729+
The conversion service will be applied to coerce the `String` value to the target type of a missing property.
730+
731+
Referring to the previous example, if no properties are bound to `Security`, the `MyProperties` instance will contain a `null` value for `security`.
729732
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:
730733

731734
[source,java,indent=0,subs="verbatim"]

spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/AbstractMetadataGenerationTests.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2019 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
1818

1919
import java.io.File;
2020
import java.io.IOException;
21+
import java.util.Arrays;
2122

2223
import org.junit.jupiter.api.BeforeEach;
2324
import org.junit.jupiter.api.io.TempDir;
@@ -54,4 +55,11 @@ protected ConfigurationMetadata compile(Class<?>... types) {
5455
return processor.getMetadata();
5556
}
5657

58+
protected ConfigurationMetadata compile(File... sources) {
59+
TestConfigurationMetadataAnnotationProcessor processor = new TestConfigurationMetadataAnnotationProcessor(
60+
this.compiler.getOutputLocation());
61+
this.compiler.getTask(Arrays.asList(sources)).call(processor);
62+
return processor.getMetadata();
63+
}
64+
5765
}

spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2021 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,7 +16,15 @@
1616

1717
package org.springframework.boot.configurationprocessor;
1818

19+
import java.io.File;
20+
import java.io.FileWriter;
21+
import java.io.IOException;
22+
import java.io.PrintWriter;
23+
1924
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.condition.EnabledForJreRange;
26+
import org.junit.jupiter.api.condition.JRE;
27+
import org.junit.jupiter.api.io.TempDir;
2028

2129
import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata;
2230
import org.springframework.boot.configurationprocessor.metadata.ItemMetadata;
@@ -402,4 +410,40 @@ void recursivePropertiesDoNotCauseAStackOverflow() {
402410
compile(RecursiveProperties.class);
403411
}
404412

413+
@Test
414+
@EnabledForJreRange(min = JRE.JAVA_16)
415+
void explicityBoundRecordProperties(@TempDir File temp) throws IOException {
416+
File exampleRecord = new File(temp, "ExampleRecord.java");
417+
try (PrintWriter writer = new PrintWriter(new FileWriter(exampleRecord))) {
418+
writer.println("@org.springframework.boot.configurationsample.ConstructorBinding");
419+
writer.println("@org.springframework.boot.configurationsample.ConfigurationProperties(\"explicit\")");
420+
writer.println("public record ExampleRecord(String someString, Integer someInteger) {");
421+
writer.println("}");
422+
}
423+
ConfigurationMetadata metadata = compile(exampleRecord);
424+
assertThat(metadata).has(Metadata.withProperty("explicit.some-string"));
425+
assertThat(metadata).has(Metadata.withProperty("explicit.some-integer"));
426+
}
427+
428+
@Test
429+
@EnabledForJreRange(min = JRE.JAVA_16)
430+
void explicitlyBoundRecordPropertiesWithDefaultValues(@TempDir File temp) throws IOException {
431+
File exampleRecord = new File(temp, "ExampleRecord.java");
432+
try (PrintWriter writer = new PrintWriter(new FileWriter(exampleRecord))) {
433+
writer.println("@org.springframework.boot.configurationsample.ConstructorBinding");
434+
writer.println(
435+
"@org.springframework.boot.configurationsample.ConfigurationProperties(\"record.defaults\")");
436+
writer.println("public record ExampleRecord(");
437+
writer.println("@org.springframework.boot.configurationsample.DefaultValue(\"An1s9n\") String someString,");
438+
writer.println("@org.springframework.boot.configurationsample.DefaultValue(\"594\") Integer someInteger");
439+
writer.println(") {");
440+
writer.println("}");
441+
}
442+
ConfigurationMetadata metadata = compile(exampleRecord);
443+
assertThat(metadata)
444+
.has(Metadata.withProperty("record.defaults.some-string", String.class).withDefaultValue("An1s9n"));
445+
assertThat(metadata)
446+
.has(Metadata.withProperty("record.defaults.some-integer", Integer.class).withDefaultValue(594));
447+
}
448+
405449
}

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2020 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,9 +16,15 @@
1616

1717
package org.springframework.boot.context.properties;
1818

19+
import java.lang.reflect.Constructor;
1920
import java.util.Map;
2021

22+
import net.bytebuddy.ByteBuddy;
23+
import net.bytebuddy.ClassFileVersion;
24+
import net.bytebuddy.description.annotation.AnnotationDescription;
2125
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.condition.EnabledForJreRange;
27+
import org.junit.jupiter.api.condition.JRE;
2228
import org.junit.jupiter.api.function.ThrowingConsumer;
2329

2430
import org.springframework.boot.context.properties.ConfigurationPropertiesBean.BindMethod;
@@ -216,6 +222,32 @@ void forValueObjectReturnsBean() {
216222
.getBindConstructor(ConstructorBindingOnConstructor.class, false)).isNotNull();
217223
}
218224

225+
@Test
226+
@EnabledForJreRange(min = JRE.JAVA_16)
227+
void forValueObjectWithRecordReturnsBean() {
228+
Class<?> constructorBindingRecord = new ByteBuddy(ClassFileVersion.JAVA_V16).makeRecord()
229+
.name("org.springframework.boot.context.properties.RecordProperties")
230+
.annotateType(AnnotationDescription.Builder.ofType(ConfigurationProperties.class)
231+
.define("prefix", "explicit").build())
232+
.annotateType(AnnotationDescription.Builder.ofType(ConstructorBinding.class).build())
233+
.defineRecordComponent("someString", String.class).defineRecordComponent("someInteger", Integer.class)
234+
.make().load(getClass().getClassLoader()).getLoaded();
235+
ConfigurationPropertiesBean propertiesBean = ConfigurationPropertiesBean
236+
.forValueObject(constructorBindingRecord, "constructorBindingRecord");
237+
assertThat(propertiesBean.getName()).isEqualTo("constructorBindingRecord");
238+
assertThat(propertiesBean.getInstance()).isNull();
239+
assertThat(propertiesBean.getType()).isEqualTo(constructorBindingRecord);
240+
assertThat(propertiesBean.getBindMethod()).isEqualTo(BindMethod.VALUE_OBJECT);
241+
assertThat(propertiesBean.getAnnotation()).isNotNull();
242+
Bindable<?> target = propertiesBean.asBindTarget();
243+
assertThat(target.getType()).isEqualTo(ResolvableType.forClass(constructorBindingRecord));
244+
assertThat(target.getValue()).isNull();
245+
Constructor<?> bindConstructor = ConfigurationPropertiesBindConstructorProvider.INSTANCE
246+
.getBindConstructor(constructorBindingRecord, false);
247+
assertThat(bindConstructor).isNotNull();
248+
assertThat(bindConstructor.getParameterTypes()).containsExactly(String.class, Integer.class);
249+
}
250+
219251
@Test
220252
void forValueObjectWhenJavaBeanBindTypeThrowsException() {
221253
assertThatIllegalStateException()

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2021 the original author or authors.
2+
* Copyright 2012-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,23 +16,35 @@
1616

1717
package org.springframework.boot.context.properties.bind;
1818

19+
import java.io.File;
20+
import java.io.FileWriter;
21+
import java.io.IOException;
22+
import java.io.PrintWriter;
1923
import java.lang.reflect.Constructor;
24+
import java.net.URL;
25+
import java.net.URLClassLoader;
2026
import java.nio.file.Path;
2127
import java.nio.file.Paths;
2228
import java.time.LocalDate;
2329
import java.util.ArrayList;
30+
import java.util.Arrays;
2431
import java.util.List;
2532
import java.util.Map;
2633
import java.util.Objects;
2734

2835
import org.junit.jupiter.api.Test;
36+
import org.junit.jupiter.api.condition.EnabledForJreRange;
37+
import org.junit.jupiter.api.condition.JRE;
38+
import org.junit.jupiter.api.io.TempDir;
2939

3040
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
3141
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
3242
import org.springframework.boot.context.properties.source.MockConfigurationPropertySource;
43+
import org.springframework.boot.testsupport.compiler.TestCompiler;
3344
import org.springframework.core.ResolvableType;
3445
import org.springframework.core.convert.ConversionService;
3546
import org.springframework.format.annotation.DateTimeFormat;
47+
import org.springframework.test.util.ReflectionTestUtils;
3648
import org.springframework.util.Assert;
3749

3850
import static org.assertj.core.api.Assertions.assertThat;
@@ -357,6 +369,30 @@ void bindToAnnotationNamedParameter() {
357369
assertThat(bound.getImportName()).isEqualTo("test");
358370
}
359371

372+
@Test
373+
@EnabledForJreRange(min = JRE.JAVA_16)
374+
void bindToRecordWithDefaultValue(@TempDir File tempDir) throws IOException, ClassNotFoundException {
375+
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
376+
source.put("test.record.property1", "value-from-config-1");
377+
this.sources.add(source);
378+
File recordProperties = new File(tempDir, "RecordProperties.java");
379+
try (PrintWriter writer = new PrintWriter(new FileWriter(recordProperties))) {
380+
writer.println("public record RecordProperties(");
381+
writer.println(
382+
"@org.springframework.boot.context.properties.bind.DefaultValue(\"default-value-1\") String property1,");
383+
writer.println(
384+
"@org.springframework.boot.context.properties.bind.DefaultValue(\"default-value-2\") String property2");
385+
writer.println(") {");
386+
writer.println("}");
387+
}
388+
TestCompiler compiler = new TestCompiler(tempDir);
389+
compiler.getTask(Arrays.asList(recordProperties)).call();
390+
ClassLoader ucl = new URLClassLoader(new URL[] { tempDir.toURI().toURL() });
391+
Object bean = this.binder.bind("test.record", Class.forName("RecordProperties", true, ucl)).get();
392+
assertThat(ReflectionTestUtils.getField(bean, "property1")).isEqualTo("value-from-config-1");
393+
assertThat(ReflectionTestUtils.getField(bean, "property2")).isEqualTo("default-value-2");
394+
}
395+
360396
private void noConfigurationProperty(BindException ex) {
361397
assertThat(ex.getProperty()).isNull();
362398
}

0 commit comments

Comments
 (0)