BeanFieldJoin.java

/*
 * Copyright 2017 Andrew Rucker Jones.
 *
 * 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
 *
 *      http://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 com.opencsv.bean;

import com.opencsv.ICSVParser;
import com.opencsv.exceptions.CsvBadConverterException;
import com.opencsv.exceptions.CsvBeanIntrospectionException;
import com.opencsv.exceptions.CsvDataTypeMismatchException;
import org.apache.commons.collections4.ListValuedMap;
import org.apache.commons.collections4.MultiValuedMap;
import org.apache.commons.collections4.SetValuedMap;
import org.apache.commons.collections4.multimap.ArrayListValuedHashMap;
import org.apache.commons.collections4.multimap.HashSetValuedHashMap;
import org.apache.commons.lang3.ArrayUtils;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Collection;
import java.util.Locale;
import java.util.ResourceBundle;

/**
 * This class is used for combining multiple columns of the input, possibly
 * with multiple identically named columns, into one field.
 * 
 * @param <T> The type of the bean being populated
 * @param <I> The index of the {@link org.apache.commons.collections4.MultiValuedMap} in use
 * @author Andrew Rucker Jones
 * @since 4.2
 */
abstract public class BeanFieldJoin<T, I> extends BeanFieldSingleValue<T, I> {
    
    /**
     * The type of the {@link org.apache.commons.collections4.MultiValuedMap}
     * that should be instantiated for the bean field being populated.
     */
    private final Class<? extends MultiValuedMap> mapType;
    
    /**
     * Creates a new instance.
     *
     * @param type The type of the class in which this field is found. This is
     *             the type as instantiated by opencsv, and not necessarily the
     *             type in which the field is declared in the case of
     *             inheritance.
     * @param field The bean field this object represents
     * @param required Whether or not a value is always required for this field
     * @param errorLocale The locale to use for error messages
     * @param converter The converter to be used for performing the data
     *   conversion on reading or writing
     * @param mapType The type of the
     *   {@link org.apache.commons.collections4.MultiValuedMap} that should be
     *   instantiated for the bean field being populated
     * @param capture See {@link CsvBindAndJoinByName#capture()}
     * @param format The format string used for packaging values to be written.
     *               If {@code null} or empty, it is ignored.
     */
    public BeanFieldJoin(
            Class<?> type, Field field, boolean required, Locale errorLocale,
            CsvConverter converter, Class<? extends MultiValuedMap> mapType,
            String capture, String format) {
        
        // Simple assignments
        super(type, field, required, errorLocale, converter, capture, format);
        
        // Check that we really have a collection
        if(!MultiValuedMap.class.isAssignableFrom(field.getType())) {
            throw new CsvBadConverterException(
                    BeanFieldJoin.class,
                    String.format(
                            ResourceBundle.getBundle(
                                    ICSVParser.DEFAULT_BUNDLE_NAME,
                                    errorLocale).getString("invalid.multivaluedmap.type"),
                            field.getType().toString()));
        }
        
        // Determine the MultiValuedMap implementation that should be
        // instantiated for every bean.
        Class<?> fieldType = field.getType();
        if(!fieldType.isInterface()) {
            this.mapType = (Class<MultiValuedMap>)field.getType();
        }
        else if(!mapType.isInterface()) {
            this.mapType = mapType;
        }
        else {
            if(MultiValuedMap.class.equals(fieldType) || ListValuedMap.class.equals(fieldType)) {
                this.mapType = ArrayListValuedHashMap.class;
            }
            else if(SetValuedMap.class.equals(fieldType)) {
                this.mapType = HashSetValuedHashMap.class;
            }
            else {
                this.mapType = null;
                throw new CsvBadConverterException(
                        BeanFieldJoin.class,
                        String.format(
                                ResourceBundle.getBundle(
                                        ICSVParser.DEFAULT_BUNDLE_NAME,
                                        errorLocale).getString("invalid.multivaluedmap.type"),
                                mapType.toString()));
            }
        }
        
        // Now that we know what type we want to assign, run one last check
        // that assignment is truly possible
        if(!field.getType().isAssignableFrom(this.mapType)) {
            throw new CsvBadConverterException(
                    BeanFieldJoin.class,
                    String.format(
                            ResourceBundle.getBundle(
                                    ICSVParser.DEFAULT_BUNDLE_NAME,
                                    errorLocale).getString("unassignable.multivaluedmap.type"),
                            mapType.getName(), field.getType().getName()));
        }
    }
    
    /**
     * Puts the value given in {@code newValue} into {@code map} using
     * {@code index}.
     * This allows derived classes to do something special before assigning the
     * value, such as converting the index to a different type.
     * 
     * @param map The map to which to assign the new value. Never null.
     * @param index The index under which the new value should be placed in the
     *   map. Never null.
     * @param newValue The new value to be put in the map
     * @return The previous value under this index, or null if there was no
     *   previous value
     */
    abstract protected Object putNewValue(MultiValuedMap<I, Object> map, String index, Object newValue);

    /**
     * Assigns the value given to the proper field of the bean given.
     * In the case of this kind of bean field, the new value will be added to
     * an existing map, and a new map will be created if one does not already
     * exist.
     */
    // The rest of the Javadoc is inherited
    @Override
    protected void assignValueToField(Object bean, Object obj, String header)
            throws CsvDataTypeMismatchException {

        // Find and use getter and setter methods if available
        // obj == null means that the source field was empty. Then we simply
        // make certain that a(n empty) map exists.
        @SuppressWarnings("unchecked")
        MultiValuedMap<I,Object> currentValue = (MultiValuedMap<I,Object>) getFieldValue(bean);
        try {
            if(currentValue == null) {
                Constructor<? extends MultiValuedMap> c = mapType.getConstructor();
                currentValue = c.newInstance();
            }
            putNewValue(currentValue, header, obj);
            super.assignValueToField(bean, currentValue, header);
        } catch (IllegalAccessException | InvocationTargetException | ClassCastException e) {
            CsvBeanIntrospectionException csve =
                    new CsvBeanIntrospectionException(bean, field,
                            e.getLocalizedMessage());
            csve.initCause(e);
            throw csve;
        } catch(InstantiationException | NoSuchMethodException e) {
            CsvBadConverterException csve = new CsvBadConverterException(
                    BeanFieldJoin.class,
                    String.format(
                            ResourceBundle.getBundle(ICSVParser.DEFAULT_BUNDLE_NAME, errorLocale)
                                    .getString("map.cannot.be.instantiated"),
                            mapType.getName()));
            csve.initCause(e);
            throw csve;
        }
    }
    
    /**
     * @return An array of all objects in the
     *   {@link org.apache.commons.collections4.MultiValuedMap} addressed by
     *   this bean field answering to the key given in {@code index}
     */
    // The rest of the Javadoc is inherited
    @Override
    public Object[] indexAndSplitMultivaluedField(Object value, I index)
            throws CsvDataTypeMismatchException {
        Object[] splitObjects = ArrayUtils.EMPTY_OBJECT_ARRAY;
        if(value != null) {
            if(MultiValuedMap.class.isAssignableFrom(value.getClass())) {
                @SuppressWarnings("unchecked")
                MultiValuedMap<Object,Object> map = (MultiValuedMap<Object,Object>) value;
                Collection<Object> splitCollection = map.get(index);
                splitObjects = splitCollection.toArray(ArrayUtils.EMPTY_OBJECT_ARRAY);
            }
            else {
                // Note about code coverage: I sincerely doubt this code is
                // reachable. It is meant as one more safeguard.
                throw new CsvDataTypeMismatchException(value, String.class,
                        ResourceBundle.getBundle(ICSVParser.DEFAULT_BUNDLE_NAME, errorLocale)
                                .getString("field.not.multivaluedmap"));
            }
        }
        return splitObjects;
    }
    
    /**
     * Checks that {@code value} is not null and not empty.
     */
    // The rest of the Javadoc is inherited
    @Override
    @SuppressWarnings("unchecked")
    protected boolean isFieldEmptyForWrite(Object value) {
        return super.isFieldEmptyForWrite(value) || ((MultiValuedMap<Object, Object>)value).isEmpty();
    }
}