For the purpose of this short article, we will be using OpenCSV 5.7.1 with Kotlin.
The End Goal
So first and foremost, let us define what we want. We want to convert a list of our DTO, into a CSV.
data class SomeCoolDto(
val someString: String,
val someInt: Int,
)
val listOfCoolDtos = listOf(
SomeCoolDto(
someString = "hey", someInt = 7874
)
)
Should turn into:
"someString","someInt"
"hey","7874"
Try 1.0 : Let’s use @CsvBindByName
At first glance, I’m thinking, hey, this annotation seems pretty similar to what we need. We just specify column name and voila!
data class SomeCoolDto(
@CsvBindByName(column = "some string")
val someString: String,
@CsvBindByName(column = "some int")
val someInt: Int,
)
val listOfCoolDtos = listOf(
SomeCoolDto(
someString = "hey", someInt = 7874
)
)
fun toByteArray(listOfDto: List<SomeCoolDto>): ByteArray {
val outputStream = ByteArrayOutputStream()
OutputStreamWriter(outputStream).use { writer ->
val csvWriter = StatefulBeanToCsvBuilder<SomeCoolDto>(writer)
.build()
csvWriter.write(listOfDto)
}
return outputStream.toByteArray()
}
//This is how we can verify our CSV
val bytes = toByteArray(listOfCoolDtos)
println(bytes.toString(UTF_8).trimIndent())
And if we actually run the above code, we get the following:
"SOME INT","SOME STRING"
"7874","hey"
We’re getting somewhere, but it still needs a little help. Here’s what’s missing:
- Hey, why are my headers all capitalised! I want it to follow my variable naming which is camel cased.
- And why isn’t the order of my variable declaration followed! It should be
someString
followed bysomeInt
just like how it is in my DTO. - I like to keep things dynamic too. If I change my variable name of my DTO, I want to only change it at one place. Basic Don’t repeat yourself (DRY) principles.
Try 2.0 : Let’s use @CsvBindByPosition
Which brings us to our second try. I spotted something that might potentially be useful: @CsvBindByPosition
.
This is how our new DTO will look like with our new annotations:
data class SomeCoolDto(
@CsvBindByPosition(position = 0)
val someString: String,
@CsvBindByPosition(position = 1)
val someInt: Int,
)
Similarly, we can use our toByteArray
function above, which gives us this output:
"hey","7874"
But looks like our headers are missing. This means we might need to handle the headers and write it to our CSV on our own.
Try 2.1 : @CsvBindByPosition + appending headers
Let’s have our very own generateCsvHeaders
function which takes in our class object, and spits out a list of it’s declared fields, in ascending CsvBindByPosition
index positions.
fun generateCsvHeaders(clazz: Class<*>): List<String> {
return clazz.declaredFields
.sortedBy { field ->
(
field.getAnnotation(CsvBindByPosition::class.java)?.position ?: field.getAnnotation(
CsvBindAndSplitByPosition::class.java,
)?.position
) ?: Int.MAX_VALUE
}
.mapNotNull { field ->
field.name
}
}
We then tweak our toByteArray
function to include our new headers.
fun toByteArray(listOfDto: List<SomeCoolDto>): ByteArray {
val outputStream = ByteArrayOutputStream()
OutputStreamWriter(outputStream).use { writer ->
val csvWriter = StatefulBeanToCsvBuilder<SomeCoolDto>(writer)
.build()
//2 New lines to add headers here
val headers = generateCsvHeaders(SomeCoolDto::class.java)
writer.appendLine(headers.joinToString(","))
csvWriter.write(listOfDto)
}
return outputStream.toByteArray()
}
Output:
someString,someInt
"hey","7874"
This is almost like what we want. But remember, we want dynamic. In the future if we change the order of variable declaration, we want our CSV headers to change accordingly as well, without needing to change any other variables (in this case position index value).
Last try: ColumnPositionMappingStrategy + appending headers
Now, our final form. Our DTO still looks like the original, clean and annotation free.
data class SomeCoolDto(
val someString: String,
val someInt: Int,
)
We then have a generateCsvHeaders
function which gives us a list of the headers according to the variable declaration order.
fun generateCsvHeaders(clazz: Class<*>): List<String> {
return clazz.declaredFields
.mapNotNull { field ->
field.name
}
}
We then use ColumnPositionMappingStrategy
to denote the mapping of the CSV columns with the order declaration. Additionally, the headers are also written at the top of the CSV.
fun toByteArray(listOfDto: List<SomeCoolDto>): ByteArray {
val outputStream = ByteArrayOutputStream()
// Get headers
val headers = generateCsvHeaders(SomeCoolDto::class.java)
// Define column mapping strategy
val strategy = ColumnPositionMappingStrategy<SomeCoolDto>()
strategy.type = SomeCoolDto::class.java
strategy.setColumnMapping(*headers.toTypedArray())
OutputStreamWriter(outputStream).use { writer ->
val csvWriter = CSVWriter(writer)
// Write headers
csvWriter.writeNext(headers.toTypedArray())
val beanToCsv = StatefulBeanToCsvBuilder<SomeCoolDto>(writer)
.withMappingStrategy(strategy)
.build()
beanToCsv.write(listOfDto)
}
return outputStream.toByteArray()
}
This will give us the following output:
"someString","someInt"
"hey","7874"
The best part about this, in the future, say our new colleague adds a new variable, or even switch the order declaration. No code change is needed! The headers and column data will all fall in place perfectly.
Inspired by Franz Wong who wrote a different variation but in Java here:
https://dev.to/franzwong/writing-csv-file-with-opencsv-without-capitalized-headers-and-follows-declaration-order-207e
Top comments (0)