This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new a6092f243dd1 CAMEL-26085: camel-csv - marshal the maps of a list in
the columns of their keys (#28593)
a6092f243dd1 is described below
commit a6092f243dd15ca854eac320e23b3463df251d8c
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 28 21:34:26 2026 +0530
CAMEL-26085: camel-csv - marshal the maps of a list in the columns of their
keys (#28593)
The maps of a list are marshalled in the columns of their keys, with the
columns of all rows, instead of the order of the values of each map. A null key
and a sorted map are handled. The change in output for mixed record types and
the cost of the column scan are documented in the data format page and the 4.24
upgrade guide, with List rows or a custom marshallerFactory as the way back.
Co-Authored-By: Claude <[email protected]>
---
.../apache/camel/catalog/docs/csv-dataformat.adoc | 20 +++
.../camel-csv/src/main/docs/csv-dataformat.adoc | 20 +++
.../apache/camel/dataformat/csv/CsvMarshaller.java | 68 +++++++-
.../csv/CsvMarshalMapRowsColumnOrderTest.java | 184 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 19 +++
5 files changed, 302 insertions(+), 9 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/csv-dataformat.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/csv-dataformat.adoc
index f1b977cc7e13..c8fe3efceb67 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/csv-dataformat.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/csv-dataformat.adoc
@@ -182,6 +182,26 @@ format, you can now store the message payload as a
`List<Map<String, Object>>` object where the list contains a Map for
each row.
+When `header` is set (and `headerDisabled` is false), its names are the
+columns, in that order. Otherwise, the columns are the keys of the first Map,
+followed by the keys of later Maps that the first one does not have, in the
+order they first appear (the rows written before such a key appears have
+fewer fields). A Map without one of these keys gets a null value in that
+column, and no header line is written.
+
+So a body that mixes record types, such as a header row `{type=H, date=d}`,
+detail rows `{type=D, sku=s, qty=q}` and a trailer row `{type=T, count=n}`,
+is written as `H,d`, `D,,s,q` and `T,,,,n`. To write each row with only its
+own fields, in the order of its Map, convert the rows to Lists first, or set
+a custom `marshallerFactory` (`marshallerFactoryRef` in XML and YAML) whose
+`CsvMarshaller` returns the values of each Map from `getMapRecordValues`.
+
+Every row is written with all the columns seen so far, so the output grows
+with the number of rows times the number of distinct keys. A body of rows
+that each have keys of their own (such as sparse or pivoted data) gives
+about N²/2 fields for N rows; for such data, convert the rows to Lists, or
+set `header` to only the columns you need.
+
=== File Poller of CSV, then unmarshaling
Given a bean which can handle the incoming data...
diff --git a/components/camel-csv/src/main/docs/csv-dataformat.adoc
b/components/camel-csv/src/main/docs/csv-dataformat.adoc
index f1b977cc7e13..c8fe3efceb67 100644
--- a/components/camel-csv/src/main/docs/csv-dataformat.adoc
+++ b/components/camel-csv/src/main/docs/csv-dataformat.adoc
@@ -182,6 +182,26 @@ format, you can now store the message payload as a
`List<Map<String, Object>>` object where the list contains a Map for
each row.
+When `header` is set (and `headerDisabled` is false), its names are the
+columns, in that order. Otherwise, the columns are the keys of the first Map,
+followed by the keys of later Maps that the first one does not have, in the
+order they first appear (the rows written before such a key appears have
+fewer fields). A Map without one of these keys gets a null value in that
+column, and no header line is written.
+
+So a body that mixes record types, such as a header row `{type=H, date=d}`,
+detail rows `{type=D, sku=s, qty=q}` and a trailer row `{type=T, count=n}`,
+is written as `H,d`, `D,,s,q` and `T,,,,n`. To write each row with only its
+own fields, in the order of its Map, convert the rows to Lists first, or set
+a custom `marshallerFactory` (`marshallerFactoryRef` in XML and YAML) whose
+`CsvMarshaller` returns the values of each Map from `getMapRecordValues`.
+
+Every row is written with all the columns seen so far, so the output grows
+with the number of rows times the number of distinct keys. A body of rows
+that each have keys of their own (such as sparse or pivoted data) gives
+about N²/2 fields for N rows; for such data, convert the rows to Lists, or
+set `header` to only the columns you need.
+
=== File Poller of CSV, then unmarshaling
Given a bean which can handle the incoming data...
diff --git
a/components/camel-csv/src/main/java/org/apache/camel/dataformat/csv/CsvMarshaller.java
b/components/camel-csv/src/main/java/org/apache/camel/dataformat/csv/CsvMarshaller.java
index 6cef2740940e..13079072c991 100644
---
a/components/camel-csv/src/main/java/org/apache/camel/dataformat/csv/CsvMarshaller.java
+++
b/components/camel-csv/src/main/java/org/apache/camel/dataformat/csv/CsvMarshaller.java
@@ -22,8 +22,11 @@ import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
import org.apache.camel.Exchange;
import org.apache.camel.NoTypeConversionAvailableException;
@@ -80,13 +83,14 @@ public abstract class CsvMarshaller {
throws NoTypeConversionAvailableException, IOException {
CSVPrinter printer = createPrinter(exchange, outputStream);
try {
+ Function<Map<?, ?>, Iterable<?>> mapRecordValues =
createMapRecordValues();
if (object instanceof Map map) {
- printer.printRecord(getMapRecordValues(map));
+ printer.printRecord(mapRecordValues.apply(map));
} else {
Iterator it = ObjectHelper.createIterator(object);
while (it.hasNext()) {
Object child = it.next();
- printer.printRecord(getRecordValues(exchange, child));
+ printer.printRecord(getRecordValues(exchange, child,
mapRecordValues));
}
}
} finally {
@@ -107,11 +111,12 @@ public abstract class CsvMarshaller {
return new CSVPrinter(new OutputStreamWriter(outputStream,
ExchangeHelper.getCharsetName(exchange)), format);
}
- private Iterable<?> getRecordValues(Exchange exchange, Object data) throws
NoTypeConversionAvailableException {
+ private Iterable<?> getRecordValues(Exchange exchange, Object data,
Function<Map<?, ?>, Iterable<?>> mapRecordValues)
+ throws NoTypeConversionAvailableException {
// each row must be a map or list based
Map<?, ?> map =
exchange.getContext().getTypeConverter().tryConvertTo(Map.class, exchange,
data);
if (map != null) {
- return getMapRecordValues(map);
+ return mapRecordValues.apply(map);
}
return ExchangeHelper.convertToMandatoryType(exchange, List.class,
data);
}
@@ -124,6 +129,17 @@ public abstract class CsvMarshaller {
*/
protected abstract Iterable<?> getMapRecordValues(Map<?, ?> map);
+ /**
+ * Creates the function that gives the CSV record values of the map rows
of one marshalled body. It is created for
+ * each body, so it can keep the columns of the rows already written.
+ *
+ * @return the function that gives the CSV record values of a map row
+ * @since 4.24
+ */
+ protected Function<Map<?, ?>, Iterable<?>> createMapRecordValues() {
+ return this::getMapRecordValues;
+ }
+
//region Implementations
/**
@@ -148,7 +164,9 @@ public abstract class CsvMarshaller {
}
/**
- * This marshaller adapts the columns but always keep them in the same
order
+ * This marshaller takes the columns from the keys of the map rows. Within
one body each key keeps its column: the
+ * keys of the first row, then the keys of later rows in the order they
are first seen. A row without one of these
+ * keys gets a null value in its column.
*/
private static final class DynamicColumnsMarshaller extends CsvMarshaller {
private DynamicColumnsMarshaller(CSVFormat format) {
@@ -156,11 +174,43 @@ public abstract class CsvMarshaller {
}
@Override
- protected Iterable<?> getMapRecordValues(Map<?, ?> map) {
- List<Object> result = new ArrayList<>(map.size());
- result.addAll(map.values());
+ protected Function<Map<?, ?>, Iterable<?>> createMapRecordValues() {
+ // the columns of one body, so each key keeps its column in every
row, whatever the iteration order of the
+ // row map; a new body starts with no columns, as the rows of
another message may have other keys
+ Set<Object> columns = new LinkedHashSet<>();
+ return map -> {
+ columns.addAll(map.keySet());
+ List<Object> result = new ArrayList<>(columns.size());
+ for (Object column : columns) {
+ result.add(valueOf(map, column));
+ }
+ return result;
+ };
+ }
- return result;
+ private static Object valueOf(Map<?, ?> map, Object column) {
+ if (column == null) {
+ // a null key of an earlier row: maps such as Map.of(...) do
not permit get(null)
+ for (Map.Entry<?, ?> entry : map.entrySet()) {
+ if (entry.getKey() == null) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+ try {
+ return map.get(column);
+ } catch (ClassCastException e) {
+ // a sorted map, such as a TreeMap, cannot compare a key of an
earlier row of another type, so it
+ // has no value for it
+ return null;
+ }
+ }
+
+ @Override
+ protected Iterable<?> getMapRecordValues(Map<?, ?> map) {
+ // a map on its own: its keys are the columns
+ return createMapRecordValues().apply(map);
}
}
//endregion
diff --git
a/components/camel-csv/src/test/java/org/apache/camel/dataformat/csv/CsvMarshalMapRowsColumnOrderTest.java
b/components/camel-csv/src/test/java/org/apache/camel/dataformat/csv/CsvMarshalMapRowsColumnOrderTest.java
new file mode 100644
index 000000000000..9ef7d0984993
--- /dev/null
+++
b/components/camel-csv/src/test/java/org/apache/camel/dataformat/csv/CsvMarshalMapRowsColumnOrderTest.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.dataformat.csv;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+/**
+ * A List of Maps without a configured header: every key must stay in one
column, whatever the iteration order of each
+ * row map, and a key missing in a row must not shift the following values
into the wrong column. Each body has its own
+ * columns.
+ */
+class CsvMarshalMapRowsColumnOrderTest extends CamelTestSupport {
+ @EndpointInject("mock:output")
+ MockEndpoint output;
+
+ @Test
+ void rowsWithTheSameKeysInAnotherOrderStayAligned() throws Exception {
+ output.expectedMessageCount(1);
+
+ template.sendBody("direct:default", List.of(
+ row("id", "1", "name", "Alice", "city", "Oslo"),
+ row("name", "Bob", "city", "Rome", "id", "2")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "1,Alice,Oslo", "2,Bob,Rome" },
readOutputLines(0));
+ }
+
+ @Test
+ void rowWithAMissingKeyDoesNotShiftTheFollowingColumns() throws Exception {
+ output.expectedMessageCount(1);
+
+ template.sendBody("direct:default", List.of(
+ row("id", "1", "name", "Alice", "city", "Oslo"),
+ row("id", "2", "city", "Rome")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "1,Alice,Oslo", "2,,Rome" },
readOutputLines(0));
+ }
+
+ @Test
+ void rowWithAMissingKeyGetsTheNullString() throws Exception {
+ output.expectedMessageCount(1);
+
+ template.sendBody("direct:nullString", List.of(
+ row("id", "1", "name", "Alice", "city", "Oslo"),
+ row("id", "2", "city", "Rome")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "1,Alice,Oslo", "2,NULL,Rome" },
readOutputLines(0));
+ }
+
+ @Test
+ void keyThatTheFirstRowDoesNotHaveGetsTheNextColumn() throws Exception {
+ output.expectedMessageCount(1);
+
+ template.sendBody("direct:default", List.of(
+ row("id", "1", "name", "Alice"),
+ row("city", "Rome", "id", "2"),
+ row("name", "Carol", "city", "Bergen", "id", "3")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "1,Alice", "2,,Rome",
"3,Carol,Bergen" }, readOutputLines(0));
+ }
+
+ @Test
+ void rowsOfDifferentRecordTypesGetTheColumnsOfAllRows() throws Exception {
+ output.expectedMessageCount(1);
+
+ template.sendBody("direct:default", List.of(
+ row("type", "H", "date", "20261028"),
+ row("type", "D", "sku", "A1", "qty", "2"),
+ row("type", "T", "count", "1")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "H,20261028", "D,,A1,2", "T,,,,1" },
readOutputLines(0));
+ }
+
+ @Test
+ void listRowsOfDifferentRecordTypesKeepTheirOwnFields() throws Exception {
+ output.expectedMessageCount(1);
+
+ template.sendBody("direct:default", List.of(
+ List.of("H", "20261028"),
+ List.of("D", "A1", "2"),
+ List.of("T", "1")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "H,20261028", "D,A1,2", "T,1" },
readOutputLines(0));
+ }
+
+ @Test
+ void nullKeyOfAnEarlierRowWithAMapThatDoesNotPermitNullKeys() throws
Exception {
+ output.expectedMessageCount(1);
+
+ Map<String, Object> withNullKey = new LinkedHashMap<>();
+ withNullKey.put("id", "1");
+ withNullKey.put(null, "x");
+ // Map.of(...) throws a NullPointerException on get(null)
+ template.sendBody("direct:default", List.of(withNullKey, Map.of("id",
"2")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "1,x", "2," }, readOutputLines(0));
+ }
+
+ @Test
+ void keyOfAnotherTypeOfAnEarlierRowWithASortedMap() throws Exception {
+ output.expectedMessageCount(1);
+
+ Map<Object, Object> first = new LinkedHashMap<>();
+ first.put(1, "x");
+ first.put("id", "1");
+ // a TreeMap of String keys throws a ClassCastException on get(1)
+ Map<Object, Object> sorted = new TreeMap<>(Map.of("id", "2"));
+ template.sendBody("direct:default", List.of(first, sorted));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "x,1", ",2" }, readOutputLines(0));
+ }
+
+ @Test
+ void eachBodyHasItsOwnColumns() throws Exception {
+ output.expectedMessageCount(2);
+
+ template.sendBody("direct:default", List.of(row("id", "1", "name",
"Alice")));
+ template.sendBody("direct:default", List.of(row("city", "Rome", "id",
"2")));
+ output.assertIsSatisfied();
+
+ assertArrayEquals(new String[] { "1,Alice" }, readOutputLines(0));
+ assertArrayEquals(new String[] { "Rome,2" }, readOutputLines(1));
+ }
+
+ private static Map<String, Object> row(String... keyValues) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ map.put(keyValues[i], keyValues[i + 1]);
+ }
+ return map;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:default")
+ .marshal(new CsvDataFormat())
+ .to("mock:output");
+
+ from("direct:nullString")
+ .marshal(new CsvDataFormat().setNullString("NULL"))
+ .to("mock:output");
+ }
+ };
+ }
+
+ private String[] readOutputLines(int index) {
+ return
output.getExchanges().get(index).getIn().getBody(String.class).split("\r\n|\r|\n");
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 205a36a7675b..7767b53870a0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1460,6 +1460,25 @@ file is written from the column names, not from the
first map). Every map of the
`ignoreFirstRecord` only applies to unmarshal. A route that set
`ignoreFirstRecord=false` to work around this is not
affected; a route that added a dummy first map now gets it in the output too.
+=== camel-csv - the maps of a list are written in the columns of their keys
+
+When no `header` is set (or `headerDisabled=true`), marshalling a
`List<Map<String, Object>>` wrote the values of
+each map in the iteration order of that map, so a row whose map had its keys
in another order, or had no entry for
+one of the keys, put its values in the wrong columns. Within one message body,
each key now keeps its column: the
+columns are the keys of the first map, followed by the keys of later maps that
the first one does not have, in the
+order they first appear (the rows written before such a key appears have fewer
fields). A map without one of these
+keys gets a null value in its column (an empty field, or the `nullString` when
it is set), where before the following
+values moved one column to the left. Each message body starts again with no
columns, as before. Set `header` (with
+`headerDisabled=false`) to get a fixed set and order of columns.
+
+This also changes the output of a body that mixes record types on purpose,
such as a header row
+`{type=H, date=d}`, detail rows `{type=D, sku=s, qty=q}` and a trailer row
`{type=T, count=n}` (common in flat-file
+exchanges): it was written as `H,d`, `D,s,q` and `T,n`, and is now written as
`H,d`, `D,,s,q` and `T,,,,n`. To keep
+the previous output, convert the rows to `List` before marshalling, or set a
custom `marshallerFactory`
+(`marshallerFactoryRef` in XML and YAML) whose `CsvMarshaller` returns the
values of each map. Every row
+now has all the columns seen so far in the body, so rows that each have keys
of their own (sparse or pivoted data)
+give about N²/2 fields for N rows, where the output was linear in the input
before.
+
=== camel-github2 - the commit consumer routes the commits beyond the newest
100
The `commit` consumer read only the 100 newest commits of the branch at each
poll, so when more than 100 commits were