Skip to content

Commit d89e305

Browse files
committed
Introduce SimplePropertyRowMapper with flexible constructor/property/field mapping
Includes query(Class) method with value and property mapping support on JdbcClient. JdbcClient's singleColumn/singleValue are declared without a Class parameter now. Closes gh-26594 See gh-30931
1 parent 443e3d5 commit d89e305

15 files changed

+617
-95
lines changed

spring-core/src/main/java/org/springframework/util/ReflectionUtils.java

+25
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,31 @@ public static Field findField(Class<?> clazz, @Nullable String name, @Nullable C
609609
return null;
610610
}
611611

612+
/**
613+
* Attempt to find a {@link Field field} on the supplied {@link Class} with the
614+
* supplied {@code name}. Searches all superclasses up to {@link Object}.
615+
* @param clazz the class to introspect
616+
* @param name the name of the field (with upper/lower case to be ignored)
617+
* @return the corresponding Field object, or {@code null} if not found
618+
* @since 6.1
619+
*/
620+
@Nullable
621+
public static Field findFieldIgnoreCase(Class<?> clazz, String name) {
622+
Assert.notNull(clazz, "Class must not be null");
623+
Assert.notNull(name, "Name must not be null");
624+
Class<?> searchType = clazz;
625+
while (Object.class != searchType && searchType != null) {
626+
Field[] fields = getDeclaredFields(searchType);
627+
for (Field field : fields) {
628+
if (name.equalsIgnoreCase(field.getName())) {
629+
return field;
630+
}
631+
}
632+
searchType = searchType.getSuperclass();
633+
}
634+
return null;
635+
}
636+
612637
/**
613638
* Set the field represented by the supplied {@linkplain Field field object} on
614639
* the specified {@linkplain Object target object} to the specified {@code value}.

spring-jdbc/src/main/java/org/springframework/jdbc/core/BeanPropertyRowMapper.java

+5-18
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
* long, Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc.
6262
*
6363
* <p>To facilitate mapping between columns and properties that don't have matching
64-
* names, try using column aliases in the SQL statement like
64+
* names, try using underscore-separated column aliases in the SQL statement like
6565
* {@code "select fname as first_name from customer"}, where {@code first_name}
6666
* can be mapped to a {@code setFirstName(String)} method in the target class.
6767
*
@@ -87,6 +87,7 @@
8787
* @since 2.5
8888
* @param <T> the result type
8989
* @see DataClassRowMapper
90+
* @see SimplePropertyRowMapper
9091
*/
9192
public class BeanPropertyRowMapper<T> implements RowMapper<T> {
9293

@@ -278,6 +279,7 @@ protected void suppressProperty(String propertyName) {
278279
* @param name the original name
279280
* @return the converted name
280281
* @since 4.2
282+
* @see #underscoreName
281283
*/
282284
protected String lowerCaseName(String name) {
283285
return name.toLowerCase(Locale.US);
@@ -289,25 +291,10 @@ protected String lowerCaseName(String name) {
289291
* @param name the original name
290292
* @return the converted name
291293
* @since 4.2
292-
* @see #lowerCaseName
294+
* @see JdbcUtils#convertPropertyNameToUnderscoreName
293295
*/
294296
protected String underscoreName(String name) {
295-
if (!StringUtils.hasLength(name)) {
296-
return "";
297-
}
298-
299-
StringBuilder result = new StringBuilder();
300-
result.append(Character.toLowerCase(name.charAt(0)));
301-
for (int i = 1; i < name.length(); i++) {
302-
char c = name.charAt(i);
303-
if (Character.isUpperCase(c)) {
304-
result.append('_').append(Character.toLowerCase(c));
305-
}
306-
else {
307-
result.append(c);
308-
}
309-
}
310-
return result.toString();
297+
return JdbcUtils.convertPropertyNameToUnderscoreName(name);
311298
}
312299

313300

spring-jdbc/src/main/java/org/springframework/jdbc/core/DataClassRowMapper.java

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
* @author Sam Brannen
5858
* @since 5.3
5959
* @param <T> the result type
60+
* @see SimplePropertyRowMapper
6061
*/
6162
public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {
6263

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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.jdbc.core;
18+
19+
import java.beans.PropertyDescriptor;
20+
import java.lang.reflect.Constructor;
21+
import java.lang.reflect.Field;
22+
import java.lang.reflect.Method;
23+
import java.sql.ResultSet;
24+
import java.sql.ResultSetMetaData;
25+
import java.sql.SQLException;
26+
import java.util.HashSet;
27+
import java.util.Map;
28+
import java.util.Set;
29+
import java.util.concurrent.ConcurrentHashMap;
30+
31+
import org.springframework.beans.BeanUtils;
32+
import org.springframework.core.MethodParameter;
33+
import org.springframework.core.convert.ConversionService;
34+
import org.springframework.core.convert.TypeDescriptor;
35+
import org.springframework.core.convert.support.DefaultConversionService;
36+
import org.springframework.jdbc.support.JdbcUtils;
37+
import org.springframework.util.Assert;
38+
import org.springframework.util.ReflectionUtils;
39+
40+
/**
41+
* {@link RowMapper} implementation that converts a row into a new instance
42+
* of the specified mapped target class. The mapped target class must be a
43+
* top-level class or {@code static} nested class, and it may expose either a
44+
* <em>data class</em> constructor with named parameters corresponding to column
45+
* names or classic bean property setter methods with property names corresponding
46+
* to column names or fields with corresponding field names.
47+
*
48+
* <p>When combining a data class constructor with setter methods, any property
49+
* mapped successfully via a constructor argument will not be mapped additionally
50+
* via a corresponding setter method or field mapping. This means that constructor
51+
* arguments take precedence over property setter methods which in turn take
52+
* precedence over direct field mappings.
53+
*
54+
* <p>To facilitate mapping between columns and properties that don't have matching
55+
* names, try using underscore-separated column aliases in the SQL statement like
56+
* {@code "select fname as first_name from customer"}, where {@code first_name}
57+
* can be mapped to a {@code setFirstName(String)} method in the target class.
58+
*
59+
* <p>This is a flexible alternative to {@link DataClassRowMapper} and
60+
* {@link BeanPropertyRowMapper} for scenarios where no specific customization
61+
* and no pre-defined property mappings are needed.
62+
*
63+
* <p>In terms of its fallback property discovery algorithm, this class is similar to
64+
* {@link org.springframework.jdbc.core.namedparam.SimplePropertySqlParameterSource}
65+
* and is similarly used for {@link org.springframework.jdbc.core.simple.JdbcClient}.
66+
*
67+
* @author Juergen Hoeller
68+
* @since 6.1
69+
* @param <T> the result type
70+
* @see DataClassRowMapper
71+
* @see BeanPropertyRowMapper
72+
* @see org.springframework.jdbc.core.simple.JdbcClient.StatementSpec#query(Class)
73+
* @see org.springframework.jdbc.core.namedparam.SimplePropertySqlParameterSource
74+
*/
75+
public class SimplePropertyRowMapper<T> implements RowMapper<T> {
76+
77+
private static final Object NO_DESCRIPTOR = new Object();
78+
79+
private final Class<T> mappedClass;
80+
81+
private final ConversionService conversionService;
82+
83+
private final Constructor<T> mappedConstructor;
84+
85+
private final String[] constructorParameterNames;
86+
87+
private final TypeDescriptor[] constructorParameterTypes;
88+
89+
private final Map<String, Object> propertyDescriptors = new ConcurrentHashMap<>();
90+
91+
92+
/**
93+
* Create a new {@code SimplePropertyRowMapper}.
94+
* @param mappedClass the class that each row should be mapped to
95+
*/
96+
public SimplePropertyRowMapper(Class<T> mappedClass) {
97+
this(mappedClass, DefaultConversionService.getSharedInstance());
98+
}
99+
100+
/**
101+
* Create a new {@code SimplePropertyRowMapper}.
102+
* @param mappedClass the class that each row should be mapped to
103+
* @param conversionService a {@link ConversionService} for binding
104+
* JDBC values to bean properties
105+
*/
106+
public SimplePropertyRowMapper(Class<T> mappedClass, ConversionService conversionService) {
107+
Assert.notNull(mappedClass, "Mapped Class must not be null");
108+
Assert.notNull(conversionService, "ConversionService must not be null");
109+
this.mappedClass = mappedClass;
110+
this.conversionService = conversionService;
111+
112+
this.mappedConstructor = BeanUtils.getResolvableConstructor(mappedClass);
113+
int paramCount = this.mappedConstructor.getParameterCount();
114+
this.constructorParameterNames = (paramCount > 0 ?
115+
BeanUtils.getParameterNames(this.mappedConstructor) : new String[0]);
116+
this.constructorParameterTypes = new TypeDescriptor[paramCount];
117+
for (int i = 0; i < paramCount; i++) {
118+
this.constructorParameterTypes[i] = new TypeDescriptor(new MethodParameter(this.mappedConstructor, i));
119+
}
120+
}
121+
122+
123+
@Override
124+
public T mapRow(ResultSet rs, int rowNumber) throws SQLException {
125+
Object[] args = new Object[this.constructorParameterNames.length];
126+
Set<Integer> usedIndex = new HashSet<>();
127+
for (int i = 0; i < args.length; i++) {
128+
String name = this.constructorParameterNames[i];
129+
int index;
130+
try {
131+
// Try direct name match first
132+
index = rs.findColumn(name);
133+
}
134+
catch (SQLException ex) {
135+
// Try underscored name match instead
136+
index = rs.findColumn(JdbcUtils.convertPropertyNameToUnderscoreName(name));
137+
}
138+
TypeDescriptor td = this.constructorParameterTypes[i];
139+
Object value = JdbcUtils.getResultSetValue(rs, index, td.getType());
140+
usedIndex.add(index);
141+
args[i] = this.conversionService.convert(value, td);
142+
}
143+
T mappedObject = BeanUtils.instantiateClass(this.mappedConstructor, args);
144+
145+
ResultSetMetaData rsmd = rs.getMetaData();
146+
int columnCount = rsmd.getColumnCount();
147+
for (int index = 1; index <= columnCount; index++) {
148+
if (!usedIndex.contains(index)) {
149+
Object desc = getDescriptor(JdbcUtils.lookupColumnName(rsmd, index));
150+
if (desc instanceof MethodParameter mp) {
151+
Method method = mp.getMethod();
152+
if (method != null) {
153+
Object value = JdbcUtils.getResultSetValue(rs, index, mp.getParameterType());
154+
value = this.conversionService.convert(value, new TypeDescriptor(mp));
155+
ReflectionUtils.makeAccessible(method);
156+
ReflectionUtils.invokeMethod(method, mappedObject, value);
157+
}
158+
}
159+
else if (desc instanceof Field field) {
160+
Object value = JdbcUtils.getResultSetValue(rs, index, field.getType());
161+
value = this.conversionService.convert(value, new TypeDescriptor(field));
162+
ReflectionUtils.makeAccessible(field);
163+
ReflectionUtils.setField(field, mappedObject, value);
164+
}
165+
}
166+
}
167+
168+
return mappedObject;
169+
}
170+
171+
private Object getDescriptor(String column) {
172+
return this.propertyDescriptors.computeIfAbsent(column, name -> {
173+
174+
// Try direct match first
175+
PropertyDescriptor pd = BeanUtils.getPropertyDescriptor(this.mappedClass, name);
176+
if (pd != null && pd.getWriteMethod() != null) {
177+
return BeanUtils.getWriteMethodParameter(pd);
178+
}
179+
Field field = ReflectionUtils.findField(this.mappedClass, name);
180+
if (field != null) {
181+
return field;
182+
}
183+
184+
// Try de-underscored match instead
185+
String adaptedName = JdbcUtils.convertUnderscoreNameToPropertyName(name);
186+
if (!adaptedName.equals(name)) {
187+
pd = BeanUtils.getPropertyDescriptor(this.mappedClass, adaptedName);
188+
if (pd != null && pd.getWriteMethod() != null) {
189+
return BeanUtils.getWriteMethodParameter(pd);
190+
}
191+
field = ReflectionUtils.findField(this.mappedClass, adaptedName);
192+
if (field != null) {
193+
return field;
194+
}
195+
}
196+
197+
// Fallback: case-insensitive match
198+
PropertyDescriptor[] pds = BeanUtils.getPropertyDescriptors(this.mappedClass);
199+
for (PropertyDescriptor candidate : pds) {
200+
if (name.equalsIgnoreCase(candidate.getName())) {
201+
return BeanUtils.getWriteMethodParameter(candidate);
202+
}
203+
}
204+
field = ReflectionUtils.findFieldIgnoreCase(this.mappedClass, name);
205+
if (field != null) {
206+
return field;
207+
}
208+
209+
return NO_DESCRIPTOR;
210+
});
211+
}
212+
213+
}

spring-jdbc/src/main/java/org/springframework/jdbc/core/SingleColumnRowMapper.java

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 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.
@@ -53,6 +53,7 @@ public class SingleColumnRowMapper<T> implements RowMapper<T> {
5353
@Nullable
5454
private ConversionService conversionService = DefaultConversionService.getSharedInstance();
5555

56+
5657
/**
5758
* Create a new {@code SingleColumnRowMapper} for bean-style configuration.
5859
* @see #setRequiredType
@@ -65,7 +66,9 @@ public SingleColumnRowMapper() {
6566
* @param requiredType the type that each result object is expected to match
6667
*/
6768
public SingleColumnRowMapper(Class<T> requiredType) {
68-
setRequiredType(requiredType);
69+
if (requiredType != Object.class) {
70+
setRequiredType(requiredType);
71+
}
6972
}
7073

7174

spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/SimplePropertySqlParameterSource.java

+16-9
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
import java.beans.PropertyDescriptor;
2020
import java.lang.reflect.Field;
21-
import java.util.HashMap;
2221
import java.util.Map;
22+
import java.util.concurrent.ConcurrentHashMap;
2323

2424
import org.springframework.beans.BeanUtils;
2525
import org.springframework.jdbc.core.StatementCreatorUtils;
@@ -44,12 +44,16 @@
4444
* @since 6.1
4545
* @see NamedParameterJdbcTemplate
4646
* @see BeanPropertySqlParameterSource
47+
* @see org.springframework.jdbc.core.simple.JdbcClient.StatementSpec#paramSource(Object)
48+
* @see org.springframework.jdbc.core.SimplePropertyRowMapper
4749
*/
4850
public class SimplePropertySqlParameterSource extends AbstractSqlParameterSource {
4951

52+
private static final Object NO_DESCRIPTOR = new Object();
53+
5054
private final Object paramObject;
5155

52-
private final Map<String, Object> descriptorMap = new HashMap<>();
56+
private final Map<String, Object> propertyDescriptors = new ConcurrentHashMap<>();
5357

5458

5559
/**
@@ -64,7 +68,7 @@ public SimplePropertySqlParameterSource(Object paramObject) {
6468

6569
@Override
6670
public boolean hasValue(String paramName) {
67-
return (getDescriptor(paramName) != null);
71+
return (getDescriptor(paramName) != NO_DESCRIPTOR);
6872
}
6973

7074
@Override
@@ -103,14 +107,17 @@ else if (desc instanceof Field field) {
103107
return TYPE_UNKNOWN;
104108
}
105109

106-
@Nullable
107110
private Object getDescriptor(String paramName) {
108-
return this.descriptorMap.computeIfAbsent(paramName, name -> {
109-
Object pd = BeanUtils.getPropertyDescriptor(this.paramObject.getClass(), name);
110-
if (pd == null) {
111-
pd = ReflectionUtils.findField(this.paramObject.getClass(), name);
111+
return this.propertyDescriptors.computeIfAbsent(paramName, name -> {
112+
PropertyDescriptor pd = BeanUtils.getPropertyDescriptor(this.paramObject.getClass(), name);
113+
if (pd != null && pd.getReadMethod() != null) {
114+
return pd;
115+
}
116+
Field field = ReflectionUtils.findField(this.paramObject.getClass(), name);
117+
if (field != null) {
118+
return field;
112119
}
113-
return pd;
120+
return NO_DESCRIPTOR;
114121
});
115122
}
116123

0 commit comments

Comments
 (0)