3

OpenCSV happily converts a record into an object if that class has a default constructor and setters for each of the fields. However, the class for which I wish to generate an object is defined with final fields, a private constructor and a Builder. For example, if I want to create an object of type X where X is defined by

public class X {
    private final String val;
    private X(final String val) { this.val = val; }
    public Builder builder() { return new Builder(); }

    public static class Builder {
        private String val;
        public Builder withVal(final String val) { this.val = val; return this; }
        public X build() { return new X(val); }
    }
}

I've seen the com.opencsv.bean.MappingStrategy interface and wonder if this can be applied somehow but I've not found a solution yet. Any ideas?

Richard Polton
  • 101
  • 1
  • 5

1 Answers1

1

com.opencsv.bean.MappingStrategy looks like a good way how to deal with this, but since you have final properties and a private constructor, you need to get a bit help from the java reflections system.

The OpenCSV's built-in mapping strategies inheriting from the AbstractMappingStrategy class are constructing the bean using a no-parameters default constructor and then finding setter methods to fill the values.

It's possible to prepare a MappingStrategy implementation that will instead find a suitable constructor with parameters matching the order and the types of mapped columns from the CSV file and use it for constructing the bean. Even if the constructor is private or protected it can be made accessible through the reflections system.


For example the following CSV:

number;string
1;abcde
2;ghijk

can be mapped to the following class:

public class Something {
    @CsvBindByName
    private final int number;
    @CsvBindByName
    private final String string;

    public Something(int number, String string) {
        this.number = number;
        this.string = string;
    }

    // ... getters, equals, toString etc. omitted
}

using the following CvsToBean instance:

CsvToBean<Something> beanLoader = new CsvToBeanBuilder<Something>(reader)
    .withType(Something.class)
    .withSeparator(';')
    .withMappingStrategy(new ImmutableClassMappingStrategy<>(Something.class))
    .build();

List<Something> result = beanLoader.parse();

The full code of ImmutableClassMappingStrategy:

import com.opencsv.bean.AbstractBeanField;
import com.opencsv.bean.BeanField;
import com.opencsv.bean.HeaderColumnNameMappingStrategy;
import com.opencsv.exceptions.CsvConstraintViolationException;
import com.opencsv.exceptions.CsvDataTypeMismatchException;
import com.opencsv.exceptions.CsvRequiredFieldEmptyException;

import java.beans.IntrospectionException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * A {@link com.opencsv.bean.MappingStrategy} implementation which allows to construct immutable beans containing
 * final fields.
 *
 * It tries to find a constructor with order and types of arguments same as the CSV lines and construct the bean using
 * this constructor. If not found it tries to use the default constructor.
 *
 * @param <T> Type of the bean to be returned
 */
public class ImmutableClassMappingStrategy<T> extends HeaderColumnNameMappingStrategy<T> {

    /**
     * Constructor
     * 
     * @param type Type of the bean which will be returned
     */
    public ColumnMappingStrategy(Class<T> type) {
        setType(type);
    }

    @Override
    public T populateNewBean(String[] line) throws InstantiationException, IllegalAccessException, IntrospectionException, InvocationTargetException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException, CsvConstraintViolationException {
        verifyLineLength(line.length);

        try {
            // try constructing the bean using explicit constructor
            return constructUsingConstructorWithArguments(line);
        } catch (NoSuchMethodException e) {
            // fallback to default constructor
            return super.populateNewBean(line);
        }
    }

    /**
     * Tries constructing the bean using a constructor with parameters for all matching CSV columns
     *
     * @param line A line of input
     *
     * @return
     * @throws NoSuchMethodException in case no suitable constructor is found
     */
    private T constructUsingConstructorWithArguments(String[] line) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Constructor<? extends T> constructor = findSuitableConstructor(line.length);

        // in case of private or protected constructor, try to set it to be accessible
        if (!constructor.canAccess(null)) {
            constructor.setAccessible(true);
        }

        Object[] arguments = prepareArguments(line);

        return constructor.newInstance(arguments);
    }

    /**
     * Tries to find a suitable constructor with exact number and types of parameters in order defined in the CSV file
     *
     * @param columns Number of columns in the CSV file
     * @return Constructor reflection
     * @throws NoSuchMethodException in case no such constructor exists
     */
    private Constructor<? extends T> findSuitableConstructor(int columns) throws NoSuchMethodException {
        Class<?>[] types = new Class<?>[columns];
        for (int col = 0; col < columns; col++) {
            BeanField<T> field = findField(col);
            Class<?> type = field.getField().getType();
            types[col] = type;
        }
        return type.getDeclaredConstructor(types);
    }

    /**
     * Prepare arguments with correct types to be used in the constructor
     *
     * @param line A line of input
     * @return Array of correctly typed argument values
     */
    private Object[] prepareArguments(String[] line) {
        Object[] arguments = new Object[line.length];
        for (int col = 0; col < line.length; col++) {
            arguments[col] = prepareArgument(col, line[col], findField(col));
        }
        return arguments;
    }

    /**
     * Prepare a single argument with correct type
     *
     * @param col Column index
     * @param value Column value
     * @param beanField Field with
     * @return
     */
    private Object prepareArgument(int col, String value, BeanField<T> beanField) {
        Field field = beanField.getField();

        // empty value for primitive type would be converted to null which would throw an NPE
        if ("".equals(value) && field.getType().isPrimitive()) {
            throw new IllegalArgumentException(String.format("Null value for primitive field '%s'", headerIndex.getByPosition(col)));
        }

        try {
            // reflectively access the convert method, as it's protected in AbstractBeanField class
            Method convert = AbstractBeanField.class.getDeclaredMethod("convert", String.class);
            convert.setAccessible(true);
            return convert.invoke(beanField, value);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            throw new IllegalStateException(String.format("Unable to convert bean field '%s'", headerIndex.getByPosition(col)), e);
        }
    }
}


Another approach might be to map the CSV columns to the builder itself and build the immutable class afterwards.

oujesky
  • 2,837
  • 1
  • 19
  • 18