Skip to content

Commit 37eaded

Browse files
committed
Support BindParam annotation
Allows customizing the name of the request parameter to bind a constructor parameter to. Closes gh-30947
1 parent ccaccda commit 37eaded

File tree

7 files changed

+329
-29
lines changed

7 files changed

+329
-29
lines changed

Diff for: spring-context/src/main/java/org/springframework/validation/DataBinder.java

+73-21
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
170170
@Nullable
171171
private String[] requiredFields;
172172

173+
@Nullable
174+
private NameResolver nameResolver;
175+
173176
@Nullable
174177
private ConversionService conversionService;
175178

@@ -225,7 +228,7 @@ public String getObjectName() {
225228

226229
/**
227230
* Set the type for the target object. When the target is {@code null},
228-
* setting the targetType allows using {@link #construct(ValueResolver)} to
231+
* setting the targetType allows using {@link #construct} to
229232
* create the target.
230233
* @param targetType the type of the target object
231234
* @since 6.1
@@ -252,7 +255,7 @@ public ResolvableType getTargetType() {
252255
* <p>Default is "true" on a standard DataBinder. Note that since Spring 4.1 this feature is supported
253256
* for bean property access (DataBinder's default mode) and field access.
254257
* <p>Used for setter/field injection via {@link #bind(PropertyValues)}, and not
255-
* applicable to constructor initialization via {@link #construct(ValueResolver)}.
258+
* applicable to constructor binding via {@link #construct}.
256259
* @see #initBeanPropertyAccess()
257260
* @see org.springframework.beans.BeanWrapper#setAutoGrowNestedPaths
258261
*/
@@ -274,7 +277,7 @@ public boolean isAutoGrowNestedPaths() {
274277
* <p>Default is 256, preventing OutOfMemoryErrors in case of large indexes.
275278
* Raise this limit if your auto-growing needs are unusually high.
276279
* <p>Used for setter/field injection via {@link #bind(PropertyValues)}, and not
277-
* applicable to constructor initialization via {@link #construct(ValueResolver)}.
280+
* applicable to constructor binding via {@link #construct}.
278281
* @see #initBeanPropertyAccess()
279282
* @see org.springframework.beans.BeanWrapper#setAutoGrowCollectionLimit
280283
*/
@@ -431,8 +434,8 @@ public BindingResult getBindingResult() {
431434
* <p>Note that this setting only applies to <i>binding</i> operations
432435
* on this DataBinder, not to <i>retrieving</i> values via its
433436
* {@link #getBindingResult() BindingResult}.
434-
* <p>Used for setter/field inject via {@link #bind(PropertyValues)}, and not
435-
* applicable to constructor initialization via {@link #construct(ValueResolver)},
437+
* <p>Used for binding to fields with {@link #bind(PropertyValues)}, and not
438+
* applicable to constructor binding via {@link #construct},
436439
* which uses only the values it needs.
437440
* @see #bind
438441
*/
@@ -456,8 +459,8 @@ public boolean isIgnoreUnknownFields() {
456459
* <p>Note that this setting only applies to <i>binding</i> operations
457460
* on this DataBinder, not to <i>retrieving</i> values via its
458461
* {@link #getBindingResult() BindingResult}.
459-
* <p>Used for setter/field inject via {@link #bind(PropertyValues)}, and not
460-
* applicable to constructor initialization via {@link #construct(ValueResolver)},
462+
* <p>Used for binding to fields with {@link #bind(PropertyValues)}, and not
463+
* applicable to constructor binding via {@link #construct},
461464
* which uses only the values it needs.
462465
* @see #bind
463466
*/
@@ -487,8 +490,8 @@ public boolean isIgnoreInvalidFields() {
487490
* <p>More sophisticated matching can be implemented by overriding the
488491
* {@link #isAllowed} method.
489492
* <p>Alternatively, specify a list of <i>disallowed</i> field patterns.
490-
* <p>Used for setter/field inject via {@link #bind(PropertyValues)}, and not
491-
* applicable to constructor initialization via {@link #construct(ValueResolver)},
493+
* <p>Used for binding to fields with {@link #bind(PropertyValues)}, and not
494+
* applicable to constructor binding via {@link #construct},
492495
* which uses only the values it needs.
493496
* @param allowedFields array of allowed field patterns
494497
* @see #setDisallowedFields
@@ -526,8 +529,8 @@ public String[] getAllowedFields() {
526529
* <p>More sophisticated matching can be implemented by overriding the
527530
* {@link #isAllowed} method.
528531
* <p>Alternatively, specify a list of <i>allowed</i> field patterns.
529-
* <p>Used for setter/field inject via {@link #bind(PropertyValues)}, and not
530-
* applicable to constructor initialization via {@link #construct(ValueResolver)},
532+
* <p>Used for binding to fields with {@link #bind(PropertyValues)}, and not
533+
* applicable to constructor binding via {@link #construct},
531534
* which uses only the values it needs.
532535
* @param disallowedFields array of disallowed field patterns
533536
* @see #setAllowedFields
@@ -562,8 +565,8 @@ public String[] getDisallowedFields() {
562565
* incoming property values, a corresponding "missing field" error
563566
* will be created, with error code "required" (by the default
564567
* binding error processor).
565-
* <p>Used for setter/field inject via {@link #bind(PropertyValues)}, and not
566-
* applicable to constructor initialization via {@link #construct(ValueResolver)},
568+
* <p>Used for binding to fields with {@link #bind(PropertyValues)}, and not
569+
* applicable to constructor binding via {@link #construct},
567570
* which uses only the values it needs.
568571
* @param requiredFields array of field names
569572
* @see #setBindingErrorProcessor
@@ -586,6 +589,28 @@ public String[] getRequiredFields() {
586589
return this.requiredFields;
587590
}
588591

592+
/**
593+
* Configure a resolver to determine the name of the value to bind to a
594+
* constructor parameter in {@link #construct}.
595+
* <p>If not configured, or if the name cannot be resolved, by default
596+
* {@link org.springframework.core.DefaultParameterNameDiscoverer} is used.
597+
* @param nameResolver the resolver to use
598+
* @since 6.1
599+
*/
600+
public void setNameResolver(NameResolver nameResolver) {
601+
this.nameResolver = nameResolver;
602+
}
603+
604+
/**
605+
* Return the {@link #setNameResolver configured} name resolver for
606+
* constructor parameters.
607+
* @since 6.1
608+
*/
609+
@Nullable
610+
public NameResolver getNameResolver() {
611+
return this.nameResolver;
612+
}
613+
589614
/**
590615
* Set the strategy to use for resolving errors into message codes.
591616
* Applies the given strategy to the underlying errors holder.
@@ -885,11 +910,19 @@ private Object createObject(ResolvableType objectType, String nestedPath, ValueR
885910
Set<String> failedParamNames = new HashSet<>(4);
886911

887912
for (int i = 0; i < paramNames.length; i++) {
888-
String paramPath = nestedPath + paramNames[i];
913+
MethodParameter param = MethodParameter.forFieldAwareConstructor(ctor, i, paramNames[i]);
914+
String lookupName = null;
915+
if (this.nameResolver != null) {
916+
lookupName = this.nameResolver.resolveName(param);
917+
}
918+
if (lookupName == null) {
919+
lookupName = paramNames[i];
920+
}
921+
922+
String paramPath = nestedPath + lookupName;
889923
Class<?> paramType = paramTypes[i];
890924
Object value = valueResolver.resolveValue(paramPath, paramType);
891925

892-
MethodParameter param = MethodParameter.forFieldAwareConstructor(ctor, i, paramNames[i]);
893926
if (value == null && !BeanUtils.isSimpleValueType(param.nestedIfOptional().getNestedParameterType())) {
894927
ResolvableType type = ResolvableType.forMethodParameter(param);
895928
args[i] = createObject(type, paramPath + ".", valueResolver);
@@ -1188,16 +1221,36 @@ else if (validator != null) {
11881221

11891222

11901223
/**
1191-
* Contract to resolve a value in {@link #construct(ValueResolver)}.
1224+
* Strategy to determine the name of the value to bind to a method parameter.
1225+
* Supported on constructor parameters with {@link #construct constructor
1226+
* binding} which performs lookups via {@link ValueResolver#resolveValue}.
1227+
*/
1228+
public interface NameResolver {
1229+
1230+
/**
1231+
* Return the name to use for the given method parameter, or {@code null}
1232+
* if unresolved. For constructor parameters, the name is determined via
1233+
* {@link org.springframework.core.DefaultParameterNameDiscoverer} if
1234+
* unresolved.
1235+
*/
1236+
@Nullable
1237+
String resolveName(MethodParameter parameter);
1238+
1239+
}
1240+
1241+
/**
1242+
* Strategy for {@link #construct constructor binding} to look up the values
1243+
* to bind to a given constructor parameter.
11921244
*/
11931245
@FunctionalInterface
11941246
public interface ValueResolver {
11951247

11961248
/**
1197-
* Look up the value for a constructor argument.
1198-
* @param name the argument name
1199-
* @param type the argument type
1200-
* @return the resolved value, possibly {@code null}
1249+
* Resolve the value for the given name and target parameter type.
1250+
* @param name the name to use for the lookup, possibly a nested path
1251+
* for constructor parameters on nested objects
1252+
* @param type the target type, based on the constructor parameter type
1253+
* @return the resolved value, possibly {@code null} if none found
12011254
*/
12021255
@Nullable
12031256
Object resolveValue(String name, Class<?> type);
@@ -1217,5 +1270,4 @@ public void registerCustomEditors(PropertyEditorRegistry registry) {
12171270
}
12181271
}
12191272

1220-
12211273
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.validation;
18+
19+
import java.beans.ConstructorProperties;
20+
import java.util.Map;
21+
import java.util.Optional;
22+
23+
import jakarta.validation.constraints.NotNull;
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.core.ResolvableType;
27+
import org.springframework.format.support.DefaultFormattingConversionService;
28+
import org.springframework.util.Assert;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
32+
/**
33+
* Unit tests for {@link DataBinder} with constructor binding.
34+
*
35+
* @author Rossen Stoyanchev
36+
*/
37+
public class DataBinderConstructTests {
38+
39+
40+
@Test
41+
void dataClassBinding() {
42+
MapValueResolver valueResolver = new MapValueResolver(Map.of("param1", "value1", "param2", "true"));
43+
DataBinder binder = initDataBinder(DataClass.class);
44+
binder.construct(valueResolver);
45+
46+
DataClass dataClass = getTarget(binder);
47+
assertThat(dataClass.param1()).isEqualTo("value1");
48+
assertThat(dataClass.param2()).isEqualTo(true);
49+
assertThat(dataClass.param3()).isEqualTo(0);
50+
}
51+
52+
@Test
53+
void dataClassBindingWithOptionalParameter() {
54+
MapValueResolver valueResolver =
55+
new MapValueResolver(Map.of("param1", "value1", "param2", "true", "optionalParam", "8"));
56+
57+
DataBinder binder = initDataBinder(DataClass.class);
58+
binder.construct(valueResolver);
59+
60+
DataClass dataClass = getTarget(binder);
61+
assertThat(dataClass.param1()).isEqualTo("value1");
62+
assertThat(dataClass.param2()).isEqualTo(true);
63+
assertThat(dataClass.param3()).isEqualTo(8);
64+
}
65+
66+
@Test
67+
void dataClassBindingWithMissingParameter() {
68+
MapValueResolver valueResolver = new MapValueResolver(Map.of("param1", "value1"));
69+
DataBinder binder = initDataBinder(DataClass.class);
70+
binder.construct(valueResolver);
71+
72+
BindingResult bindingResult = binder.getBindingResult();
73+
assertThat(bindingResult.getAllErrors()).hasSize(1);
74+
assertThat(bindingResult.getFieldValue("param1")).isEqualTo("value1");
75+
assertThat(bindingResult.getFieldValue("param2")).isNull();
76+
assertThat(bindingResult.getFieldValue("param3")).isNull();
77+
}
78+
79+
@Test
80+
void dataClassBindingWithConversionError() {
81+
MapValueResolver valueResolver = new MapValueResolver(Map.of("param1", "value1", "param2", "x"));
82+
DataBinder binder = initDataBinder(DataClass.class);
83+
binder.construct(valueResolver);
84+
85+
BindingResult bindingResult = binder.getBindingResult();
86+
assertThat(bindingResult.getAllErrors()).hasSize(1);
87+
assertThat(bindingResult.getFieldValue("param1")).isEqualTo("value1");
88+
assertThat(bindingResult.getFieldValue("param2")).isEqualTo("x");
89+
assertThat(bindingResult.getFieldValue("param3")).isNull();
90+
}
91+
92+
@SuppressWarnings("SameParameterValue")
93+
private static DataBinder initDataBinder(Class<DataClass> targetType) {
94+
DataBinder binder = new DataBinder(null);
95+
binder.setTargetType(ResolvableType.forClass(targetType));
96+
binder.setConversionService(new DefaultFormattingConversionService());
97+
return binder;
98+
}
99+
100+
@SuppressWarnings("unchecked")
101+
private static <T> T getTarget(DataBinder dataBinder) {
102+
assertThat(dataBinder.getBindingResult().getAllErrors()).isEmpty();
103+
Object target = dataBinder.getTarget();
104+
assertThat(target).isNotNull();
105+
return (T) target;
106+
}
107+
108+
109+
private static class DataClass {
110+
111+
@NotNull
112+
private final String param1;
113+
114+
private final boolean param2;
115+
116+
private int param3;
117+
118+
@ConstructorProperties({"param1", "param2", "optionalParam"})
119+
DataClass(String param1, boolean p2, Optional<Integer> optionalParam) {
120+
this.param1 = param1;
121+
this.param2 = p2;
122+
Assert.notNull(optionalParam, "Optional must not be null");
123+
optionalParam.ifPresent(integer -> this.param3 = integer);
124+
}
125+
126+
public String param1() {
127+
return this.param1;
128+
}
129+
130+
public boolean param2() {
131+
return this.param2;
132+
}
133+
134+
public int param3() {
135+
return this.param3;
136+
}
137+
}
138+
139+
140+
private static class MapValueResolver implements DataBinder.ValueResolver {
141+
142+
private final Map<String, Object> values;
143+
144+
private MapValueResolver(Map<String, Object> values) {
145+
this.values = values;
146+
}
147+
148+
@Override
149+
public Object resolveValue(String name, Class<?> type) {
150+
return values.get(name);
151+
}
152+
}
153+
154+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2002-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.bind.annotation;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
/**
26+
* Annotation to bind values from a web request such as request parameters or
27+
* path variables to fields of a Java object. Supported on constructor parameters
28+
* of {@link ModelAttribute @ModelAttribute} controller method arguments
29+
*
30+
* @author Rossen Stoyanchev
31+
* @since 6.1
32+
* @see org.springframework.web.bind.WebDataBinder#construct
33+
*/
34+
@Target(ElementType.PARAMETER)
35+
@Retention(RetentionPolicy.RUNTIME)
36+
@Documented
37+
public @interface BindParam {
38+
39+
/**
40+
* The lookup name to use for the bind value.
41+
*/
42+
String value() default "";
43+
44+
}

0 commit comments

Comments
 (0)