You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/25 21:43:32 UTC
(groovy) branch master updated: GROOVY-12295: CsvSlurper: parsing fails with useHeader=false despite documented auto-generated column names
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 47f3b75e70 GROOVY-12295: CsvSlurper: parsing fails with useHeader=false despite documented auto-generated column names
47f3b75e70 is described below
commit 47f3b75e70158a5d8099fd26be0b3140b703d507
Author: Paul King <pa...@asert.com.au>
AuthorDate: Tue Aug 25 15:57:56 2026 +1000
GROOVY-12295: CsvSlurper: parsing fails with useHeader=false despite documented auto-generated column names
---
.../src/main/java/groovy/csv/CsvSlurper.java | 107 +++++++++++++++++----
.../groovy-csv/src/spec/doc/csv-userguide.adoc | 35 +++++++
.../src/spec/test/groovy/csv/CsvSlurperTest.groovy | 69 +++++++++++++
3 files changed, 191 insertions(+), 20 deletions(-)
diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java
index 1e737e9af5..497aa600f8 100644
--- a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java
+++ b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
+import com.fasterxml.jackson.dataformat.csv.CsvParser;
import com.fasterxml.jackson.dataformat.csv.CsvSchema;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.groovy.lang.annotation.Incubating;
@@ -36,6 +37,8 @@ import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -57,6 +60,7 @@ public class CsvSlurper {
private char separator = ',';
private char quoteChar = '"';
private boolean useHeader = true;
+ private List<String> columns;
/**
* Creates a slurper that uses comma-separated columns, double quotes,
@@ -110,6 +114,33 @@ public class CsvSlurper {
return this;
}
+ /**
+ * Set explicit column names, typically for CSV without a header row.
+ * When column names are supplied and {@code useHeader} is true, the first
+ * row is still consumed as a header row but the supplied names take
+ * precedence over the names it contains.
+ *
+ * @param columns the column names
+ * @return this slurper for chaining
+ */
+ public CsvSlurper setColumns(String... columns) {
+ return setColumns(List.of(columns));
+ }
+
+ /**
+ * Set explicit column names, typically for CSV without a header row.
+ * When column names are supplied and {@code useHeader} is true, the first
+ * row is still consumed as a header row but the supplied names take
+ * precedence over the names it contains.
+ *
+ * @param columns the column names
+ * @return this slurper for chaining
+ */
+ public CsvSlurper setColumns(List<String> columns) {
+ this.columns = columns == null || columns.isEmpty() ? null : List.copyOf(columns);
+ return this;
+ }
+
/**
* Parse the content of the specified CSV text.
*
@@ -125,24 +156,43 @@ public class CsvSlurper {
/**
* Parse CSV from a reader.
- * When {@code useHeader} is true (the default), each row is returned as a map keyed
- * by column headers from the first row. When {@code useHeader} is false, maps are
- * keyed by auto-generated column names.
+ * When explicit column names have been supplied via {@link #setColumns(String...)},
+ * each row is returned as a map keyed by those names. Otherwise, when
+ * {@code useHeader} is true (the default), maps are keyed by column headers from
+ * the first row. Otherwise, maps are keyed by generated column names
+ * {@code c1}, {@code c2}, ...
*
* @param reader the reader of CSV
* @return a list of maps (one per row)
*/
public List<Map<String, String>> parse(Reader reader) {
try {
- CsvSchema schema = buildSchema();
- MappingIterator<Map<String, String>> it = mapper
- .readerFor(Map.class)
- .with(schema)
+ if (useHeader || columns != null) {
+ MappingIterator<Map<String, String>> it = mapper
+ .readerFor(Map.class)
+ .with(buildSchema())
+ .readValues(reader);
+ List<Map<String, String>> result = it.readAll();
+ // Jackson may return a single empty map for header-only input
+ if (result.size() == 1 && result.get(0).isEmpty()) {
+ result.clear();
+ }
+ return result;
+ }
+ // no header row and no explicit columns: generate column names per row
+ MappingIterator<String[]> it = mapper
+ .readerFor(String[].class)
+ .with(CsvParser.Feature.WRAP_AS_ARRAY)
+ .with(buildSchema())
.readValues(reader);
- List<Map<String, String>> result = it.readAll();
- // Jackson may return a single empty map for header-only input
- if (result.size() == 1 && result.get(0).isEmpty()) {
- result.clear();
+ List<Map<String, String>> result = new ArrayList<>();
+ while (it.hasNext()) {
+ String[] row = it.next();
+ Map<String, String> map = new LinkedHashMap<>();
+ for (int i = 0; i < row.length; i++) {
+ map.put("c" + (i + 1), row[i]);
+ }
+ result.add(map);
}
return result;
} catch (IOException e) {
@@ -232,6 +282,12 @@ public class CsvSlurper {
/**
* Parse CSV from a reader into typed objects.
+ * When explicit column names have been supplied via {@link #setColumns(String...)},
+ * values are bound to properties of those names by position. Otherwise, when
+ * {@code useHeader} is true (the default), values are bound by the column names
+ * in the header row. Otherwise, positional columns are derived from the target
+ * type's properties; use {@code @JsonPropertyOrder} on the type to control the
+ * expected column order.
*
* @param type the target type
* @param reader the reader of CSV
@@ -240,16 +296,22 @@ public class CsvSlurper {
*/
public <T> List<T> parseAs(Class<T> type, Reader reader) {
try {
- // Use empty schema with header — Jackson matches columns by name
- // rather than by position, allowing CSV column order to differ from field order
- CsvSchema schema = CsvSchema.emptySchema();
- if (useHeader) {
- schema = schema.withHeader();
+ CsvSchema schema;
+ if (columns != null) {
+ schema = buildSchema();
+ } else if (useHeader) {
+ // Use empty schema with header — Jackson matches columns by name
+ // rather than by position, allowing CSV column order to differ from field order
+ schema = CsvSchema.emptySchema().withHeader().rebuild()
+ .setColumnSeparator(separator)
+ .setQuoteChar(quoteChar)
+ .build();
+ } else {
+ schema = mapper.schemaFor(type).rebuild()
+ .setColumnSeparator(separator)
+ .setQuoteChar(quoteChar)
+ .build();
}
- schema = schema.rebuild()
- .setColumnSeparator(separator)
- .setQuoteChar(quoteChar)
- .build();
MappingIterator<T> it = mapper
.readerFor(type)
.with(schema)
@@ -316,6 +378,11 @@ public class CsvSlurper {
CsvSchema.Builder builder = CsvSchema.builder()
.setColumnSeparator(separator)
.setQuoteChar(quoteChar);
+ if (columns != null) {
+ for (String column : columns) {
+ builder.addColumn(column);
+ }
+ }
CsvSchema schema = builder.build();
if (useHeader) {
schema = schema.withHeader();
diff --git a/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc b/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc
index 862ee8c13e..e5c2eb1e03 100644
--- a/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc
+++ b/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc
@@ -60,6 +60,27 @@ character are enclosed in quotes, with embedded quotes doubled:
include::../test/groovy/csv/CsvSlurperTest.groovy[tags=quoted_fields,indent=0]
----
+=== CSV without a header row
+
+When the CSV has no header row, call `setUseHeader(false)`. Each row is returned
+as a map keyed by generated column names `c1`, `c2`, and so on:
+
+[source,groovy]
+----
+include::../test/groovy/csv/CsvSlurperTest.groovy[tags=no_header,indent=0]
+----
+
+Alternatively, supply explicit column names with `setColumns`:
+
+[source,groovy]
+----
+include::../test/groovy/csv/CsvSlurperTest.groovy[tags=explicit_columns,indent=0]
+----
+
+When explicit column names are supplied and `useHeader` remains true, the first
+row is still consumed as a header row but the supplied names take precedence
+over the names it contains.
+
=== Typed parsing
`CsvSlurper` can parse CSV directly into typed objects using Jackson databinding.
@@ -78,6 +99,20 @@ include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_parsing,indent=0]
include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_parsing_usage,indent=0]
----
+Headerless CSV can also be parsed into typed objects. Positional columns are
+derived from the target type's properties; use `@JsonPropertyOrder` to control
+the expected column order:
+
+[source,groovy]
+----
+include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_no_header,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_no_header_usage,indent=0]
+----
+
[NOTE]
====
CSV has no native types — every cell is text, so the untyped `parse`/`parseText`
diff --git a/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy
index 92981170be..6aa7831049 100644
--- a/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy
+++ b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy
@@ -18,6 +18,7 @@
*/
package groovy.csv
+import com.fasterxml.jackson.annotation.JsonPropertyOrder
import org.junit.jupiter.api.Test
import java.nio.charset.StandardCharsets
@@ -66,6 +67,47 @@ class CsvSlurperTest {
// end::quoted_fields[]
}
+ @Test
+ void testNoHeaderGeneratedColumns() {
+ // tag::no_header[]
+ def csv = new CsvSlurper().setUseHeader(false).parseText('1,2\n3,4')
+ assert csv.size() == 2
+ assert csv[0].c1 == '1'
+ assert csv[1].c2 == '4'
+ // end::no_header[]
+ }
+
+ @Test
+ void testNoHeaderExplicitColumns() {
+ // tag::explicit_columns[]
+ def csv = new CsvSlurper().setUseHeader(false).setColumns('x', 'y').parseText('1,2\n3,4')
+ assert csv[0].x == '1'
+ assert csv[1].y == '4'
+ // end::explicit_columns[]
+ }
+
+ @Test
+ void testExplicitColumnsOverrideHeader() {
+ def csv = new CsvSlurper().setColumns('x', 'y').parseText('a,b\n1,2')
+ assert csv.size() == 1
+ assert csv[0].x == '1'
+ assert csv[0].y == '2'
+ }
+
+ @Test
+ void testNoHeaderRaggedRows() {
+ def csv = new CsvSlurper().setUseHeader(false).parseText('1,2\n3,4,5')
+ assert csv[0].keySet() == ['c1', 'c2'] as Set
+ assert csv[1].c3 == '5'
+ }
+
+ @Test
+ void testNoHeaderCustomSeparator() {
+ def csv = new CsvSlurper().setUseHeader(false).setSeparator((char) '\t').parseText('1\t2')
+ assert csv[0].c1 == '1'
+ assert csv[0].c2 == '2'
+ }
+
@Test
void testEmptyInput() {
def csv = new CsvSlurper().parseText('')
@@ -156,4 +198,31 @@ class CsvSlurperTest {
assert items[0] instanceof Sale
assert items[0].amount instanceof BigDecimal
}
+
+ // tag::typed_no_header[]
+ @JsonPropertyOrder(['customer', 'amount'])
+ static class Order {
+ String customer
+ BigDecimal amount
+ }
+ // end::typed_no_header[]
+
+ @Test
+ void testTypedParsingNoHeader() {
+ // tag::typed_no_header_usage[]
+ def orders = new CsvSlurper().setUseHeader(false)
+ .parseAs(Order, 'Acme,1500.00\nGlobex,250.50')
+ assert orders.size() == 2
+ assert orders[0].customer == 'Acme'
+ assert orders[1].amount == 250.50
+ // end::typed_no_header_usage[]
+ }
+
+ @Test
+ void testTypedParsingNoHeaderExplicitColumns() {
+ def sales = new CsvSlurper().setUseHeader(false).setColumns('amount', 'customer')
+ .parseAs(Sale, '99.99,Alice')
+ assert sales[0].customer == 'Alice'
+ assert sales[0].amount == 99.99
+ }
}