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
+    }
 }