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;
	}

}