EntityRowMapper.java
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package io.github.simplejdbcmapper.core;
import java.lang.reflect.Constructor;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.convert.ConversionService;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.JdbcUtils;
import io.github.simplejdbcmapper.exception.MapperException;
/**
* A row mapper for mapped objects.
*
* Use the method
* {@link io.github.simplejdbcmapper.core.SimpleJdbcMapper#newEntityRowMapper}
* to get an instance of EntityRowMapper.
*
* <p>
* EntityRowMapper <b>always</b> has to be used with sql columns generated by
* either
*
* <pre>
* {@link io.github.simplejdbcmapper.core.SimpleJdbcMapper#getEntitySqlColumns(Class<?> entityType)} or
* {@link io.github.simplejdbcmapper.core.SimpleJdbcMapper#getEntitySqlColumns(Class<?> entityType, String tableAlias)}
* </pre>
*
* <b>Do not modify the generated sql columns</b>. EntityRowMapper expects the
* sql columns to be in a specific order since it uses position indexes to
* retrieve the data from the query ResultSet.
*
* <p>
* It will handle the cases where the column to property mappings do not follow
* the underscore to camel case naming convention.
*
* <p>
* This is the recommended row mapper to use when writing custom queries for
* mapped objects. It has optimizations which allows it to process data from the
* query ResultSet in a performant way.
*
* <p>
* Query:
*
* <pre>
* String sql = "SELECT " + sjm.getEntitySqlColumns(Product.class) + "FROM product WHERE name = ?";
* </pre>
*
* Example using JdbcTemplate:
*
* <pre>
* {@code List<Product>} products = sjm.getJdbcTemplate().query(sql, sjm.newEntityRowMapper(Product.class), "someProductName");
* </pre>
* <p>
* Example using JdbcClient. Note that the EntityRowMapper has to be passed in
* as an argument, otherwise JdbcClient will use its internal row mapper
* SimplePropertyRowMapper which will not work with the sql.
*
* <pre>
* {@code List<Product>} products = sjm.getJdbcClient().sql(sql)
* .param("someProductName")
* .query(sjm.newEntityRowMapper(Product.class))
* .list();
* </pre>
*
* @param <T> the entityType
*
* @author Antony Joseph
*/
public final class EntityRowMapper<T> implements RowMapper<T> {
private static final Logger logger = LoggerFactory.getLogger(EntityRowMapper.class);
private final ConversionService conversionService;
private final PropertyMapping[] propertyMappings;
private final Constructor<T> mappedObjConstructor;
private final int startIndex;
private final int endIndex;
@SuppressWarnings("unchecked")
EntityRowMapper(TableMapping tableMapping, ConversionService conversionService, int offset) {
this.conversionService = conversionService;
this.propertyMappings = tableMapping.getPropertyMappings();
this.mappedObjConstructor = tableMapping.getMappedObjConstructor();
// offset is used for multi entity query resultSet
this.startIndex = offset;
this.endIndex = propertyMappings.length + offset - 1;
}
@Override
public T mapRow(ResultSet rs, int rowNumber) throws SQLException {
T obj = null;
try {
obj = mappedObjConstructor.newInstance();
boolean[] typedValueExtracted = { true };
// since the sql columns were generated using the property mappings the
// resultset columns will be in same order.
for (int index = startIndex; index <= endIndex; index++) {
// propertyMappings index starts at 0
PropertyMapping propMapping = propertyMappings[index - startIndex];
Object value = getResultSetValue(rs, index, propMapping.getResultSetType(),
propMapping.getPropertyType(), typedValueExtracted);
if (typedValueExtracted[0] || value == null) {
propMapping.getWriteMethod().invoke(obj, value);
} else {
propMapping.getWriteMethod().invoke(obj,
conversionService.convert(value, propMapping.getPropertyType()));
}
}
} catch (Exception e) {
throw new MapperException(e.getMessage(), e);
}
return obj;
}
/*
* Same logic as Spring's JdbcUtil.getResultSetValue().
* JdbcUtil.getResultSetValue() logic has been proven over the years, retaining
* its logic but changed the structure to use 'switch' statement with enums
* instead of the bunch of if/else's for performance reasons. As was the goal,
* java compiled the switch statement into a 'tableswitch' which means the
* program will jump directly to the correct 'case' block in one step.
*/
private Object getResultSetValue(ResultSet rs, int index, ResultSetType resultSetType, Class<?> requiredType,
boolean[] typedValueExtracted) throws SQLException {
typedValueExtracted[0] = true;
Object value;
// Explicitly extract typed value, as far as possible.
switch (resultSetType) {
case ResultSetType.STRING:
return rs.getString(index);
case ResultSetType.BOOLEAN:
value = rs.getBoolean(index);
break;
case ResultSetType.BYTE:
value = rs.getByte(index);
break;
case ResultSetType.SHORT:
value = rs.getShort(index);
break;
case ResultSetType.INTEGER:
value = rs.getInt(index);
break;
case ResultSetType.LONG:
value = rs.getLong(index);
break;
case ResultSetType.FLOAT:
value = rs.getFloat(index);
break;
case ResultSetType.DOUBLE:
value = rs.getDouble(index);
break;
case ResultSetType.NUMBER: // same as double
value = rs.getDouble(index);
break;
case ResultSetType.BIGDECIMAL:
return rs.getBigDecimal(index);
case ResultSetType.DATE:
return rs.getDate(index);
case ResultSetType.TIME:
return rs.getTime(index);
case ResultSetType.TIMESTAMP:
return rs.getTimestamp(index);
case ResultSetType.UTILDATE: // java.util.Date. same as timestamp
return rs.getTimestamp(index);
case ResultSetType.BYTEARRAY:
return rs.getBytes(index);
case ResultSetType.BLOB:
return rs.getBlob(index);
case ResultSetType.CLOB:
return rs.getClob(index);
case ResultSetType.ENUM:
typedValueExtracted[0] = false;
// Enums are represented as a String in simpleJdbcMapper.
// leave enum type conversion up to the caller (for example, a
// ConversionService) but make sure that we return nothing other than a String
Object obj = rs.getObject(index);
if (obj instanceof String) {
return obj;
} else {
// for example, on Postgres: getObject returns a PGObject, but we need a String
return rs.getString(index);
}
default:
// Some unknown type desired -> rely on getObject.
try {
return rs.getObject(index, requiredType);
} catch (SQLFeatureNotSupportedException | AbstractMethodError ex) {
if (logger.isDebugEnabled()) {
logger.debug("JDBC driver does not support JDBC 4.1 'getObject(int, Class)' method", ex);
}
} catch (SQLException ex) {
if (logger.isDebugEnabled()) {
logger.debug("JDBC driver has limited support for 'getObject(int, Class)' with column type: "
+ requiredType.getName(), ex);
}
}
typedValueExtracted[0] = false;
// Corresponding SQL types for JSR-310, left up to the caller to convert
// them (for example, through a ConversionService).
String typeName = requiredType.getSimpleName();
return switch (typeName) {
case "LocalDate" -> rs.getDate(index);
case "LocalTime" -> rs.getTime(index);
case "LocalDateTime" -> rs.getTimestamp(index);
// Fall back to getObject without type specification, again
// left up to the caller to convert the value if necessary.
default -> JdbcUtils.getResultSetValue(rs, index);
};
}
// Perform was-null check if necessary (for results that the JDBC driver returns
// as primitives).
return (rs.wasNull() ? null : value);
}
@Override
public String toString() {
return mappedObjConstructor.getName() + " startIndex: " + startIndex + " endIndex: " + endIndex;
}
}