BeanPropertyRowMapper is ill-suited to the task, however, since that requires a no-arg constructor that records don't have and since the properties in records don't look like normal JavaBean properties - they're int foo()/foo(int) instead of int getFoo()/setFoo(int).
Comment From: joshlong
OK, I managed to get a working prototype going here.
Given a record Reservation:
record Reservation(Integer id, String firstName) {
}
...and a SQL table RESERVATION with column first_name and id, the following RowMapper<T> implementation works.
The problem is that there are no setters for records. The only way to initialize record is to use a constructor, and there may be many records in a given record class.
This class makes no attempt to use a ConversionService yet.
/**
* Maps data from a JDBC {@code ResultSet} into Java 14 record objects of type T.
*
* @author <a href="mailto:josh@joshlong.com">Josh Long</a>
*/
class RecordRowMapper<T> implements RowMapper<T> {
private final Log logger = LogFactory.getLog(getClass());
private final Map<String, RecordComponent> mappedFields = new HashMap<>();
private final List<String> parameterNames = new ArrayList<>();
private final Constructor<?> constructor;
/**
* Instantiate a new instance using the default constructor for a class.
*/
public RecordRowMapper(Class<T> tClass) {
this(tClass.getConstructors()[0]);
}
/**
* If there's ambiguity as to which constructor to invoke, provide one explicitly.
*/
public RecordRowMapper(Constructor<?> constructor) {
Assert.notNull(constructor, "the Constructor reference should not be null");
this.constructor = constructor;
for (RecordComponent recordComponent : constructor.getDeclaringClass().getRecordComponents()) {
this.mappedFields.put(normalizeName(recordComponent.getName()), recordComponent);
}
for (Parameter parameter : constructor.getParameters()) {
parameterNames.add(parameter.getName());
}
}
@Override
public T mapRow(ResultSet rs, int rowNum) throws SQLException {
ResultSetMetaData metaData = rs.getMetaData();
Map<String, Object> objectProperties = new HashMap<>();
int columnCount = metaData.getColumnCount();
for (int index = 1; index <= columnCount; index++) {
String column = JdbcUtils.lookupColumnName(metaData, index);
RecordComponent recordComponent = this.mappedFields.get(normalizeName(column));
if (null != recordComponent) {
if (rowNum == 0 && logger.isDebugEnabled()) {
logger.debug("Mapping column '" + column + "' to property '" + recordComponent.getName() +
"' of type '" + ClassUtils.getQualifiedName(recordComponent.getDeclaringRecord()) + "'");
}
objectProperties.put(recordComponent.getName(), getColumnValue(rs, index, recordComponent.getType()));
}
}
try {
return (T) this.constructor.newInstance(
this.parameterNames.stream().map(objectProperties::get).toArray()
);
}
catch (Exception e) {
ReflectionUtils.rethrowRuntimeException(e);
}
return null;
}
private String normalizeName(String name) {
return name.toLowerCase().replaceAll("_", "");
}
private Object getColumnValue(ResultSet rs, int index, Class<?> clzz) throws SQLException {
return JdbcUtils.getResultSetValue(rs, index, clzz);
}
}
So, it's possible to make it work. Is there any appetite to support something like this in Spring Framework now, or at some point in the future?
Comment From: jhoeller
Good idea! We got related enhancement requests for our constructor-bound web data binding already where we're also aiming for proper record class support in 5.3.
Comment From: jhoeller
I've addressed this with a general DataClassRowMapper which combines bean property capabilities with constructor-based binding, depending on the given mapped class, similar to our data class binding in web handler methods. This is based on constructor parameter naming with no reference to record-specific reflection APIs, so it'll work with similarly styled classes as well.