Jan Bodnar
Jan Bodnar

Reputation: 11657

No default constructor found with Java record and BeanPropertyRowMapper

I am playing with the new Java 14 and Spring Boot. I have used the new cool record instead of a regular Java class for data holders.

public record City(Long id, String name, Integer population) {}

Later in my service class, I use Spring BeanPropertyRowMapper to fetch data.

@Override
public City findById(Long id) {
    String sql = "SELECT * FROM cities WHERE id = ?";
    return jtm.queryForObject(sql, new Object[]{id},
            new BeanPropertyRowMapper<>(City.class));
}

I end up with the following error:

Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.zetcode.model.City]: No default constructor found; nested exception is java.lang.NoSuchMethodException: com.zetcode.model.City.<init>()
    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:145) ~[spring-beans-5.2.3.RELEASE.jar:5.2.3.RELEASE]

How to add a default constructor for a record or is there any other way to fix this?

Upvotes: 10

Views: 8123

Answers (3)

Pascoal Eddy Bayonne
Pascoal Eddy Bayonne

Reputation: 73

You can use DataClassRowMapper<T> extends BeanPropertyRowMapper<T> which is from Spring.jdbc.core

Here is an example of my JdbcReader:

public JdbcCursorItemReader<Sales> salesJdbcCursorItemReader() {
    var sqlStatement = "SELECT sale_id, product_id, customer_id, sale_date, sale_amount, store_location, country FROM Sales WHERE processed = false";
    return new JdbcCursorItemReaderBuilder<Sales>()
            .name("sales reader")
            .dataSource(dataSource)
            .sql(sqlStatement)
            .fetchSize(100)
            .rowMapper(new DataClassRowMapper<>(Sales.class))
            .build();
}

Upvotes: 0

etech
etech

Reputation: 2761

You can use DataClassRowMapper. It works fine with records.

@Override
public City findById(Long id) {
    String sql = "SELECT * FROM cities WHERE id = ?";
    return jtm.queryForObject(sql, new DataClassRowMapper<>(City.class), id);
}

Upvotes: 16

slesh
slesh

Reputation: 2017

Just declare it explicitly by providing default for fields:

public record City(Long id, String name, Integer population) {
    public City() {
        this(0L, "", 0)
    }
}

An important note. BeanPropertyRowMapper scans setters/getters to inflate your record instance, as record is immutable, there is not setter and it's not compatible with java beans specification, you'll get and empty record. Please, read this SO. The only way to create a record is by using a constructor. So, you have two options: either use plain java bean or implement your custom row mapper.

The esiest way how it may look like is this:

@Override
public City findById(final Long id) {
    final var sql = "SELECT * FROM cities WHERE id = ?";
    return jtm.queryForObject(
            sql,
            new Object[]{ id },
            (rs, rowNum) -> new City(
                    rs.getLong("id"),
                    rs.getString("name"),
                    rs.getInt("population")));
}

or you may use reflection:

Reflection API

The following public methods will be added to java.lang.Class:

RecordComponent[] getRecordComponents()
boolean isRecord()

The method getRecordComponents() returns an array of java.lang.reflect.RecordComponent objects, where java.lang.reflect.RecordComponent is a new class. The elements of this array correspond to the record’s components, in the same order as they appear in the record declaration. Additional information can be extracted from each RecordComponent in the array, including its name, type, generic type, annotations, and its accessor method.

The method isRecord() returns true if the given class was declared as a record. (Compare with isEnum().)

Using those methods and Class#getConstructor(Class... parameterTypes) and Constructor#newInstance(Object... initargs) you can dynamically create record. But keep it in mind that reflection may bring some overhead and affect you performace.

I've added an example of RecordRowMapper using reflection and a couple of tests:

package by.slesh.spring.jdbc.core;

import org.springframework.jdbc.IncorrectResultSetColumnCountException;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.JdbcUtils;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.RecordComponent;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.*;

public class RecordRowMapper<T> implements RowMapper<T> {
    private final Constructor<T> ctor;
    private final List<Arg> args;

    public RecordRowMapper(final Class<T> model) {
        if (!model.isRecord()) {
            throw new IllegalArgumentException(
                    model + " should be a record class");
        }
        final RecordComponent[] components = model.getRecordComponents();
        this.args = new ArrayList<>(components.length);
        final Class<?>[] argTypes = new Class[components.length];
        for (int i = 0; i < components.length; ++i) {
            final RecordComponent c = components[i];
            this.args.add(new Arg(i, c.getName(), c.getType()));
            argTypes[i] = c.getType();
        }
        try {
            this.ctor = model.getConstructor(argTypes);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(
                    "Couldn resolve constructor for types " + Arrays.toString(argTypes));
        }
    }

    @Override
    public T mapRow(final ResultSet resultSet, final int rowNumber) throws SQLException {
        final var metaData = resultSet.getMetaData();
        final int columnCount = metaData.getColumnCount();
        if (columnCount < args.size()) {
            throw new IncorrectResultSetColumnCountException(
                    args.size(), columnCount);
        }
        try {
            return ctor.newInstance(extractCtorParams(
                    resultSet, createPropertyToColumnIndexMap(
                            metaData, columnCount)));
        } catch (InstantiationException
                | IllegalAccessException
                | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }

    private Object[] extractCtorParams(
            final ResultSet resultSet,
            final Map<String, Integer> propertyToColumnIndexMap)
            throws SQLException {
        final var params = new Object[args.size()];
        for (final var arg : args) {
            final int columnIndex = propertyToColumnIndexMap.get(arg.name);
            params[arg.order] = JdbcUtils.getResultSetValue(
                    resultSet, columnIndex, arg.type);
        }
        return params;
    }

    private Map<String, Integer> createPropertyToColumnIndexMap(
            final ResultSetMetaData metaData,
            final int columnCount)
            throws SQLException {
        final Map<String, Integer> columnPropertyToIndexMap = new HashMap<>(columnCount);
        for (int columnIndex = 1; columnIndex <= columnCount; ++columnIndex) {
            final String propertyName = JdbcUtils.convertUnderscoreNameToPropertyName(
                    JdbcUtils.lookupColumnName(metaData, columnIndex));
            columnPropertyToIndexMap.put(propertyName, columnIndex);
        }
        return columnPropertyToIndexMap;
    }

    private static record Arg(int order, String name, Class<?>type) {
    }
}

Upvotes: 9

Related Questions