Skip to content

Instantly share code, notes, and snippets.

@ammmze
Last active June 1, 2023 07:12
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save ammmze/ec0334d107cb63c586ffd8fc51ec5757 to your computer and use it in GitHub Desktop.
Save ammmze/ec0334d107cb63c586ffd8fc51ec5757 to your computer and use it in GitHub Desktop.
opencsv HeaderColumnNameAndOrderMappingStrategy

HeaderColumnNameAndOrderMappingStrategy

This creates a MappingStrategy for use with OpenCSV (specifically tested for generating a CSV from beans) which does the following:

  1. Preserves the column name casing in the @CsvBindByName annotation
  2. Adds a @CsvBindByNameOrder annotation you can apply to the bean class to define the order of the columns.
  • Any field not included in the order, but is still annotated with @CsvBindName will still be included AFTER all the columns that have a defined order. Those remaining columns will be added in alphabetical order (this is the default behavior of the HeaderColumnNameMappingStrategy)
  1. Overrides the converter used with @CsvDate to use a custom converter that adds support for the java time api (LocalDate, LocalTime, and LocalDateTime are tested)

Usage

Annotate your bean with something like...

@CsvBindByNameOrder({"Foo","Bar"})
public class MyBean {
    @CsvBindByName(column = "Foo")
    private String foo;
    
    @CsvBindByName(column = "Bar")
    private String bar;
    
    // getter/setters omitted for brevity
}

Setup your writer...

List<MyBean> beans = new ArrayList();
MyBean bean = new MyBean();
bean.setFoo("fooit");
bean.setBar("barit");
beans.add(bean);

StringWriter writer = new StringWriter();
StatefulBeanToCsv<MyBean> csvWriter = new StatefulBeanToCsvBuilder<MyBean>(writer)
    .withApplyQuotesToAll(false)
    .withMappingStrategy(new HeaderColumnNameAndOrderMappingStrategy<>(MyBean.class))
    .build();
csvWriter.write(beans);
return writer.toString();

Results

With the above you should get something like...

Foo,Bar
fooit,barit

package com.example.csv;
import com.opencsv.bean.ConverterDate;
import com.opencsv.exceptions.CsvDataTypeMismatchException;
import java.lang.reflect.InvocationTargetException;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.Temporal;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
public class ConverterDateAndJavaTime extends ConverterDate {
private static String DEFAULT_FORMAT = "yyyyMMdd'T'HHmmss";
private static String DEFAULT_DATE_ONLY_FORMAT = "yyyyMMdd";
private static String DEFAULT_TIME_ONLY_FORMAT = "HHmmss";
private DateTimeFormatter format;
/**
* @param type The type of the field being populated
* @param formatString The string to use for formatting the date. See
* {@link com.opencsv.bean.CsvDate#value()}
* @param locale If not null or empty, specifies the locale used for
* converting locale-specific data types
* @param errorLocale The locale to use for error messages.
*/
public ConverterDateAndJavaTime(Class<?> type, String locale, Locale errorLocale, String formatString) {
super(type, locale, errorLocale, formatString);
// if the type is LocalDate and using the default format, we know it will fail. Lets use just the date portion of the default date format
if (DEFAULT_FORMAT.equals(formatString) && LocalDate.class.isAssignableFrom(type)) {
formatString = DEFAULT_DATE_ONLY_FORMAT;
} else if (DEFAULT_FORMAT.equals(formatString) && LocalTime.class.isAssignableFrom(type)) {
formatString = DEFAULT_TIME_ONLY_FORMAT;
}
if (this.locale != null) {
format = DateTimeFormatter.ofPattern(formatString, this.locale);
} else {
format = DateTimeFormatter.ofPattern(formatString);
}
}
@Override
public Object convertToRead(String value) throws CsvDataTypeMismatchException {
if (StringUtils.isNotBlank(value) && Temporal.class.isAssignableFrom(type)) {
return convertToTemporal(value);
}
return super.convertToRead(value);
}
@Override
public String convertToWrite(Object value) throws CsvDataTypeMismatchException {
if (value != null && Temporal.class.isAssignableFrom(value.getClass())) {
return convertFromTemporal((Temporal) value);
}
return super.convertToWrite(value);
}
private Temporal convertToTemporal(String value) {
try {
return (Temporal) type.getMethod("parse", CharSequence.class, DateTimeFormatter.class).invoke(null, value, format);
} catch (NoSuchMethodException|IllegalAccessException|InvocationTargetException e) {
throw new RuntimeException("Failed to invoke the parse method of " + type.getName(), e);
}
}
private String convertFromTemporal(Temporal value) {
return format.format(value);
}
}
package com.example.csv;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import com.opencsv.bean.CsvConverter;
import com.opencsv.bean.CsvDate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Locale;
import org.junit.Test;
public class ConverterDateAndJavaTimeTest {
private static final Locale ERROR_LOCALE = Locale.getDefault();
private static final String LOCALE = ERROR_LOCALE.toString();
private static String DEFAULT_FORMAT;
static {
try {
DEFAULT_FORMAT = (String) CsvDate.class.getMethod("value").getDefaultValue();
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}
@Test
public void convertToRead_When_ValueIsBlank_Expect_ToReceiveNull() throws Exception {
assertNull(converter(LocalDate.class, DEFAULT_FORMAT).convertToRead(""));
}
@Test
public void convertToRead_When_ValueIsValidAndTypeIsLocalDate_Expect_ToReceiveLocalDate() throws Exception {
LocalDate actual = (LocalDate) converter(LocalDate.class, DEFAULT_FORMAT).convertToRead("20190214");
assertNotNull(actual);
assertEquals(2019, actual.getYear());
assertEquals(2, actual.getMonthValue());
assertEquals(14, actual.getDayOfMonth());
}
@Test
public void convertToRead_When_ValueIsValidAndTypeIsLocalTime_Expect_ToReceiveLocalTime() throws Exception {
LocalTime actual = (LocalTime) converter(LocalTime.class, DEFAULT_FORMAT).convertToRead("221546");
assertNotNull(actual);
assertEquals(22, actual.getHour());
assertEquals(15, actual.getMinute());
assertEquals(46, actual.getSecond());
}
@Test
public void convertToRead_When_ValueIsValidAndTypeIsLocalDateTime_Expect_ToReceiveLocalDateTime() throws Exception {
LocalDateTime actual = (LocalDateTime) converter(LocalDateTime.class, DEFAULT_FORMAT).convertToRead("20190214T221546");
assertNotNull(actual);
assertEquals(2019, actual.getYear());
assertEquals(2, actual.getMonthValue());
assertEquals(14, actual.getDayOfMonth());
assertEquals(22, actual.getHour());
assertEquals(15, actual.getMinute());
assertEquals(46, actual.getSecond());
}
@Test
public void convertToWrite_When_ValueIsLocalDate_Expect_ToReceiveFormattedDate() throws Exception {
String actual = converter(LocalDate.class, DEFAULT_FORMAT).convertToWrite(LocalDate.of(2019, 2, 14));
assertEquals("20190214", actual);
}
@Test
public void convertToWrite_When_ValueIsLocalTime_Expect_ToReceiveFormattedTime() throws Exception {
String actual = converter(LocalTime.class, DEFAULT_FORMAT).convertToWrite(LocalTime.of(22, 15, 46));
assertEquals("221546", actual);
}
@Test
public void convertToWrite_When_ValueIsLocalDateTime_Expect_ToReceiveFormattedDateTime() throws Exception {
String actual = converter(LocalDateTime.class, DEFAULT_FORMAT).convertToWrite(LocalDateTime.of(2019, 2, 14,22, 15, 46));
assertEquals("20190214T221546", actual);
}
private CsvConverter converter(Class type, String format) {
return new ConverterDateAndJavaTime(type, LOCALE, ERROR_LOCALE, format);
}
}
package com.example.csv;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CsvBindByNameOrder {
String[] value() default {};
}
package com.example.csv;
import com.opencsv.bean.BeanField;
import com.opencsv.bean.CsvBindByName;
import com.opencsv.bean.CsvCustomBindByName;
import com.opencsv.bean.HeaderColumnNameMappingStrategy;
import com.opencsv.bean.comparator.LiteralComparator;
import com.opencsv.exceptions.CsvBadConverterException;
import com.opencsv.exceptions.CsvRequiredFieldEmptyException;
import java.util.Arrays;
import org.apache.commons.lang3.StringUtils;
public class HeaderColumnNameAndOrderMappingStrategy<T> extends HeaderColumnNameMappingStrategy<T> {
public HeaderColumnNameAndOrderMappingStrategy(Class<T> type) {
setType(type);
}
@Override
public String[] generateHeader(T bean) throws CsvRequiredFieldEmptyException {
// overriding this method to allow us to preserve the header column name casing
String[] header = super.generateHeader(bean);
final int numColumns = findMaxFieldIndex();
if (!isAnnotationDriven() || numColumns == -1) {
return header;
}
header = new String[numColumns + 1];
BeanField beanField;
for (int i = 0; i <= numColumns; i++) {
beanField = findField(i);
String columnHeaderName = extractHeaderName(beanField);
header[i] = columnHeaderName;
}
return header;
}
@Override
protected void loadFieldMap() throws CsvBadConverterException {
// overriding this method to support setting column order by the custom `CsvBindByNameOrder` annotation
if (writeOrder == null && type.isAnnotationPresent(CsvBindByNameOrder.class)) {
setColumnOrderOnWrite(
new LiteralComparator<>(Arrays.stream(type.getAnnotation(CsvBindByNameOrder.class).value())
.map(String::toUpperCase).toArray(String[]::new)));
}
super.loadFieldMap();
}
private String extractHeaderName(final BeanField beanField) {
if (beanField == null || beanField.getField() == null
|| beanField.getField().getDeclaredAnnotationsByType(CsvBindByName.class).length == 0) {
return StringUtils.EMPTY;
}
if (beanField.getField().isAnnotationPresent(CsvBindByName.class)) {
return beanField.getField().getDeclaredAnnotationsByType(CsvBindByName.class)[0].column();
} else if (beanField.getField().isAnnotationPresent(CsvCustomBindByName.class)) {
return beanField.getField().getDeclaredAnnotationsByType(CsvCustomBindByName.class)[0].column();
}
return StringUtils.EMPTY;
}
}
package com.example.csv;
import com.opencsv.bean.AbstractCsvConverter;
import com.opencsv.bean.BeanField;
import com.opencsv.bean.CsvBindByName;
import com.opencsv.bean.CsvConverter;
import com.opencsv.bean.CsvCustomBindByName;
import com.opencsv.bean.CsvDate;
import com.opencsv.bean.HeaderColumnNameMappingStrategy;
import com.opencsv.bean.comparator.LiteralComparator;
import com.opencsv.exceptions.CsvBadConverterException;
import com.opencsv.exceptions.CsvRequiredFieldEmptyException;
import java.lang.reflect.Field;
import java.util.Arrays;
import org.apache.commons.lang3.StringUtils;
public class HeaderColumnNameAndOrderMappingStrategy<T> extends HeaderColumnNameMappingStrategy<T> {
public HeaderColumnNameAndOrderMappingStrategy(Class<T> type) {
setType(type);
}
@Override
public String[] generateHeader(T bean) throws CsvRequiredFieldEmptyException {
// overriding this method to allow us to preserve the header column name casing
String[] header = super.generateHeader(bean);
final int numColumns = findMaxFieldIndex();
if (!isAnnotationDriven() || numColumns == -1) {
return header;
}
header = new String[numColumns + 1];
BeanField beanField;
for (int i = 0; i <= numColumns; i++) {
beanField = findField(i);
String columnHeaderName = extractHeaderName(beanField);
header[i] = columnHeaderName;
}
return header;
}
@Override
protected void loadFieldMap() throws CsvBadConverterException {
// overriding this method to support setting column order by the custom `CsvBindByNameOrder` annotation
if (writeOrder == null && type.isAnnotationPresent(CsvBindByNameOrder.class)) {
setColumnOrderOnWrite(
new LiteralComparator<>(Arrays.stream(type.getAnnotation(CsvBindByNameOrder.class).value())
.map(String::toUpperCase).toArray(String[]::new)));
}
super.loadFieldMap();
}
@Override
protected CsvConverter determineConverter(Field field, Class<?> elementType, String locale,
Class<? extends AbstractCsvConverter> customConverter) throws CsvBadConverterException {
// overrides the converter for the `CsvDate` to use our custom converter that supports java.time api
// A custom converter always takes precedence if specified.
if(customConverter != null && !customConverter.equals(AbstractCsvConverter.class)) {
return super.determineConverter(field, elementType, locale, customConverter);
}
// Perhaps a date instead
else if(field.isAnnotationPresent(CsvDate.class)) {
String formatString = field.getAnnotation(CsvDate.class).value();
return new ConverterDateAndJavaTime(elementType, locale, errorLocale, formatString);
}
return super.determineConverter(field, elementType, locale, customConverter);
}
private String extractHeaderName(final BeanField beanField) {
if (beanField == null || beanField.getField() == null) {
return StringUtils.EMPTY;
}
if (beanField.getField().isAnnotationPresent(CsvBindByName.class)) {
return beanField.getField().getDeclaredAnnotationsByType(CsvBindByName.class)[0].column();
} else if (beanField.getField().isAnnotationPresent(CsvCustomBindByName.class)) {
return beanField.getField().getDeclaredAnnotationsByType(CsvCustomBindByName.class)[0].column();
}
return StringUtils.EMPTY;
}
}
@alfredosotil
Copy link

what is the version of opencsv that you are using for?

@ariielm
Copy link

ariielm commented Nov 19, 2019

With the version 4.6 is ok, but with the 5.0 there are some issues like deprecated method usages.

@nerdmeeting
Copy link

nerdmeeting commented Dec 15, 2020

With the version 4.6 is ok, but with the 5.0 there are some issues like deprecated method usages.

Hi @ariielm,

I was able to get this working with opencsv version 5.2 by

  • changing findMaxFieldIndex() to headerIndex.findMaxIndex() + 1
  • removing !isAnnotationDriven() ||

from lines 27-31 of HeaderColumnNameAndOrderMappingStrategy.java.
Lines 27-31 now look like the following in my project and works perfectly:

String[] header = super.generateHeader(bean);
final int numColumns = headerIndex.findMaxIndex();
if (numColumns == -1) {
    return header;
}

Big thanks to @ammmze for creating this. It was exactly what I needed!

@aarrsseni
Copy link

Hi @ariielm,
Just try it on 5.3, it works fine with some changes from @nerdmeeting.
Also in 5.3 LiteralComparator was deprecated, that's why loadFieldMap() method will look like this:

    @Override
    protected void loadFieldMap() throws CsvBadConverterException {
        // overriding this method to support setting column order by the custom `CsvBindByNameOrder` annotation
        if (writeOrder == null && type.isAnnotationPresent(CsvBindByNameOrder.class)) {
            List<String> predefinedList = Arrays.stream(type.getAnnotation(CsvBindByNameOrder.class).value())
                .map(String::toUpperCase).collect(Collectors.toList());
            FixedOrderComparator<String> fixedComparator = new FixedOrderComparator<>(predefinedList);
            fixedComparator.setUnknownObjectBehavior(FixedOrderComparator.UnknownObjectBehavior.AFTER);
            Comparator<String> comparator = new ComparatorChain<>(Arrays.asList(
                fixedComparator,
                new NullComparator<>(false),
                new ComparableComparator<>()));
            setColumnOrderOnWrite(comparator);
        }
        super.loadFieldMap();
    }

Thanks for this.

@creckord
Copy link

creckord commented Jul 5, 2021

Nice. Are you releasing this under any particular license?

@itobey
Copy link

itobey commented May 26, 2022

Thanks @ammmze and @nerdmeeting , exactly what I needed!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment