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 a1b1c8b719b0 CAMEL-26150: camel-clickhouse - return binary query 
results as byte[] and fail List inserts with a clear message (#28922)
a1b1c8b719b0 is described below

commit a1b1c8b719b0f1f83e3a2a618c1ae0701675684a
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 2 20:47:07 2026 +0100

    CAMEL-26150: camel-clickhouse - return binary query results as byte[] and 
fail List inserts with a clear message (#28922)
    
    * CAMEL-26150: camel-clickhouse - return binary query results as byte[]…
    
    … and fail List inserts with a clear message
    
    A query with a binary format (RowBinary, Native, Parquet, ...) decoded the
    result as UTF-8 text into a String, which replaced the bytes that are not
    valid UTF-8. Binary formats now give a byte[] body; text formats keep a 
String.
    
    Inserting a List of Maps, or a List of objects when no class is registered 
on
    the Client for the table, failed with a NullPointerException from client-v2.
    Both now fail with a ClickHouseException that says why.
    
    The CamelClickHouseReadRows header is documented as the rows the server 
read,
    not the rows in the result.
    
    * CAMEL-26150: camel-clickhouse - the insert error does not fail on an …
    
    …empty List
    
    When the client threw a NullPointerException for an empty List body, 
building the message with
    batch.get(0) threw an IndexOutOfBoundsException and hid the cause. The 
message now handles an
    empty List (and a null first element).
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/catalog/components/clickhouse.json       |  4 +-
 .../camel/catalog/docs/clickhouse-component.adoc   | 13 ++--
 .../camel/component/clickhouse/clickhouse.json     |  4 +-
 .../src/main/docs/clickhouse-component.adoc        | 13 ++--
 .../component/clickhouse/ClickHouseConstants.java  |  4 +-
 .../component/clickhouse/ClickHouseEndpoint.java   | 13 ++--
 .../component/clickhouse/ClickHouseProducer.java   | 55 +++++++++++---
 .../clickhouse/ClickHouseProducerTest.java         | 85 +++++++++++++++++++++-
 .../integration/ClickHouseProducerIT.java          | 42 +++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    | 11 +++
 .../dsl/ClickHouseEndpointBuilderFactory.java      |  4 +-
 11 files changed, 213 insertions(+), 35 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/clickhouse.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/clickhouse.json
index db94b15eabb7..25b52c50cbcc 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/clickhouse.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/clickhouse.json
@@ -39,12 +39,12 @@
     "CamelClickHouseTable": { "index": 2, "kind": "header", "displayName": "", 
"group": "producer", "label": "", "required": false, "javaType": "String", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "Overrides the target table configured on the endpoint.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#TABLE" },
     "CamelClickHouseFormat": { "index": 3, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "String", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "Overrides the data format configured on the endpoint.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#FORMAT" },
     "CamelClickHouseWrittenRows": { "index": 4, "kind": "header", 
"displayName": "", "group": "producer", "label": "", "required": false, 
"javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "The number of rows written by an insert 
operation.", "constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#WRITTEN_ROWS" },
-    "CamelClickHouseReadRows": { "index": 5, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "long", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "The number of rows read by a query operation.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#READ_ROWS" },
+    "CamelClickHouseReadRows": { "index": 5, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "long", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "The number of rows the server read (scanned) to run a 
query operation. This is not the number of rows in the result: with a WHERE or 
a LIMIT it is usually more.", "constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#RE [...]
     "CamelClickHousePingOk": { "index": 6, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "boolean", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "The boolean result of a ping operation.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#PING_OK" }
   },
   "bodyTypes": {
     "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "list" ], 
"byOption": [ { "option": "operation", "value": "QUERY", "carriers": [ "text", 
"bytes", "stream", "file" ] }, { "option": "operation", "value": "PING", 
"carriers": [ "any" ] } ], "description": "The rows to insert, in the format 
option (JSONEachRow: one JSON object per line), as text, bytes, a stream or a 
file; or a List of objects whose class is registered on the Client. A Map or a 
List of Maps fails: marshal [...]
-    "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option": 
"operation", "value": "QUERY", "carriers": [ "text" ], "javaType": 
"java.lang.String" }, { "option": "operation", "value": "PING", "carriers": [ 
"value" ], "javaType": "java.lang.Boolean" } ], "description": "Insert leaves 
the body unchanged, with the count in the CamelClickHouseWrittenRows header. 
Query gives the result as a String in the format option (JSONEachRow: one JSON 
object per line); binary formats such [...]
+    "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option": 
"operation", "value": "QUERY", "carriers": [ "text", "bytes" ] }, { "option": 
"operation", "value": "PING", "carriers": [ "value" ], "javaType": 
"java.lang.Boolean" } ], "description": "Insert leaves the body unchanged, with 
the count in the CamelClickHouseWrittenRows header. Query gives the result in 
the format option: a String for text formats (JSONEachRow: one JSON object per 
line), a byte[] for binary formats [...]
   },
   "properties": {
     "database": { "index": 0, "kind": "path", "displayName": "Database", 
"group": "producer", "label": "", "required": true, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", 
"autowired": false, "secret": false, "description": "The ClickHouse database. A 
table may also be provided using the database.table syntax." },
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/clickhouse-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/clickhouse-component.adoc
index add6e2933a59..a2a03d957ed4 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/clickhouse-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/clickhouse-component.adoc
@@ -60,11 +60,14 @@ The component is producer-only and supports the following 
operations, selected w
 | Operation | Description
 | `insert` (default) | Streams the message body into the target table using 
the configured `format`. Accepted body
 types are `InputStream`, `byte[]`, `String`, `java.io.File` (matched to the 
`format`), or a `List` of objects
-whose class has been registered on the shared `Client`. When the body is a 
`List` and `batchSize` is greater
-than 0, the list is split into batches of that size, each sent as a separate 
insert. The number of written
-rows is set on the `CamelClickHouseWrittenRows` header.
-| `query` | Runs the SQL statement contained in the message body and returns 
the result set (in the configured
-`format`) as a `String` body. The number of read rows is set on the 
`CamelClickHouseReadRows` header.
+whose class has been registered on the shared `Client` with 
`Client.register(Class, TableSchema)`. A `List` of
+`Map` is not supported: marshal the rows to the `format` (such as 
`JSONEachRow`) first. When the body is a `List`
+and `batchSize` is greater than 0, the list is split into batches of that 
size, each sent as a separate insert.
+The number of written rows is set on the `CamelClickHouseWrittenRows` header.
+| `query` | Runs the SQL statement contained in the message body and returns 
the result set in the configured
+`format`: a `String` body for text formats (such as `JSONEachRow`, `CSV` or 
`TSV`), and a `byte[]` body for binary
+formats (such as `RowBinary`, `Native`, `Parquet`, `Arrow` or `Avro`). The 
`CamelClickHouseReadRows` header is set
+to the number of rows the server read to run the query, which is not the 
number of rows in the result.
 | `ping` | Pings the ClickHouse server and sets the boolean result on both the 
message body and the
 `CamelClickHousePingOk` header.
 |===
diff --git 
a/components/camel-clickhouse/src/generated/resources/META-INF/org/apache/camel/component/clickhouse/clickhouse.json
 
b/components/camel-clickhouse/src/generated/resources/META-INF/org/apache/camel/component/clickhouse/clickhouse.json
index db94b15eabb7..25b52c50cbcc 100644
--- 
a/components/camel-clickhouse/src/generated/resources/META-INF/org/apache/camel/component/clickhouse/clickhouse.json
+++ 
b/components/camel-clickhouse/src/generated/resources/META-INF/org/apache/camel/component/clickhouse/clickhouse.json
@@ -39,12 +39,12 @@
     "CamelClickHouseTable": { "index": 2, "kind": "header", "displayName": "", 
"group": "producer", "label": "", "required": false, "javaType": "String", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "Overrides the target table configured on the endpoint.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#TABLE" },
     "CamelClickHouseFormat": { "index": 3, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "String", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "Overrides the data format configured on the endpoint.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#FORMAT" },
     "CamelClickHouseWrittenRows": { "index": 4, "kind": "header", 
"displayName": "", "group": "producer", "label": "", "required": false, 
"javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "The number of rows written by an insert 
operation.", "constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#WRITTEN_ROWS" },
-    "CamelClickHouseReadRows": { "index": 5, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "long", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "The number of rows read by a query operation.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#READ_ROWS" },
+    "CamelClickHouseReadRows": { "index": 5, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "long", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "The number of rows the server read (scanned) to run a 
query operation. This is not the number of rows in the result: with a WHERE or 
a LIMIT it is usually more.", "constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#RE [...]
     "CamelClickHousePingOk": { "index": 6, "kind": "header", "displayName": 
"", "group": "producer", "label": "", "required": false, "javaType": "boolean", 
"deprecated": false, "deprecationNote": "", "autowired": false, "secret": 
false, "description": "The boolean result of a ping operation.", 
"constantName": 
"org.apache.camel.component.clickhouse.ClickHouseConstants#PING_OK" }
   },
   "bodyTypes": {
     "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "list" ], 
"byOption": [ { "option": "operation", "value": "QUERY", "carriers": [ "text", 
"bytes", "stream", "file" ] }, { "option": "operation", "value": "PING", 
"carriers": [ "any" ] } ], "description": "The rows to insert, in the format 
option (JSONEachRow: one JSON object per line), as text, bytes, a stream or a 
file; or a List of objects whose class is registered on the Client. A Map or a 
List of Maps fails: marshal [...]
-    "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option": 
"operation", "value": "QUERY", "carriers": [ "text" ], "javaType": 
"java.lang.String" }, { "option": "operation", "value": "PING", "carriers": [ 
"value" ], "javaType": "java.lang.Boolean" } ], "description": "Insert leaves 
the body unchanged, with the count in the CamelClickHouseWrittenRows header. 
Query gives the result as a String in the format option (JSONEachRow: one JSON 
object per line); binary formats such [...]
+    "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option": 
"operation", "value": "QUERY", "carriers": [ "text", "bytes" ] }, { "option": 
"operation", "value": "PING", "carriers": [ "value" ], "javaType": 
"java.lang.Boolean" } ], "description": "Insert leaves the body unchanged, with 
the count in the CamelClickHouseWrittenRows header. Query gives the result in 
the format option: a String for text formats (JSONEachRow: one JSON object per 
line), a byte[] for binary formats [...]
   },
   "properties": {
     "database": { "index": 0, "kind": "path", "displayName": "Database", 
"group": "producer", "label": "", "required": true, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", 
"autowired": false, "secret": false, "description": "The ClickHouse database. A 
table may also be provided using the database.table syntax." },
diff --git 
a/components/camel-clickhouse/src/main/docs/clickhouse-component.adoc 
b/components/camel-clickhouse/src/main/docs/clickhouse-component.adoc
index add6e2933a59..a2a03d957ed4 100644
--- a/components/camel-clickhouse/src/main/docs/clickhouse-component.adoc
+++ b/components/camel-clickhouse/src/main/docs/clickhouse-component.adoc
@@ -60,11 +60,14 @@ The component is producer-only and supports the following 
operations, selected w
 | Operation | Description
 | `insert` (default) | Streams the message body into the target table using 
the configured `format`. Accepted body
 types are `InputStream`, `byte[]`, `String`, `java.io.File` (matched to the 
`format`), or a `List` of objects
-whose class has been registered on the shared `Client`. When the body is a 
`List` and `batchSize` is greater
-than 0, the list is split into batches of that size, each sent as a separate 
insert. The number of written
-rows is set on the `CamelClickHouseWrittenRows` header.
-| `query` | Runs the SQL statement contained in the message body and returns 
the result set (in the configured
-`format`) as a `String` body. The number of read rows is set on the 
`CamelClickHouseReadRows` header.
+whose class has been registered on the shared `Client` with 
`Client.register(Class, TableSchema)`. A `List` of
+`Map` is not supported: marshal the rows to the `format` (such as 
`JSONEachRow`) first. When the body is a `List`
+and `batchSize` is greater than 0, the list is split into batches of that 
size, each sent as a separate insert.
+The number of written rows is set on the `CamelClickHouseWrittenRows` header.
+| `query` | Runs the SQL statement contained in the message body and returns 
the result set in the configured
+`format`: a `String` body for text formats (such as `JSONEachRow`, `CSV` or 
`TSV`), and a `byte[]` body for binary
+formats (such as `RowBinary`, `Native`, `Parquet`, `Arrow` or `Avro`). The 
`CamelClickHouseReadRows` header is set
+to the number of rows the server read to run the query, which is not the 
number of rows in the result.
 | `ping` | Pings the ClickHouse server and sets the boolean result on both the 
message body and the
 `CamelClickHousePingOk` header.
 |===
diff --git 
a/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseConstants.java
 
b/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseConstants.java
index 1bc03b65844c..7cd3dfae9d25 100644
--- 
a/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseConstants.java
+++ 
b/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseConstants.java
@@ -31,7 +31,9 @@ public final class ClickHouseConstants {
     public static final String FORMAT = "CamelClickHouseFormat";
     @Metadata(description = "The number of rows written by an insert 
operation.", javaType = "long")
     public static final String WRITTEN_ROWS = "CamelClickHouseWrittenRows";
-    @Metadata(description = "The number of rows read by a query operation.", 
javaType = "long")
+    @Metadata(description = "The number of rows the server read (scanned) to 
run a query operation. This is not the"
+                            + " number of rows in the result: with a WHERE or 
a LIMIT it is usually more.",
+              javaType = "long")
     public static final String READ_ROWS = "CamelClickHouseReadRows";
     @Metadata(description = "The boolean result of a ping operation.", 
javaType = "boolean")
     public static final String PING_OK = "CamelClickHousePingOk";
diff --git 
a/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseEndpoint.java
 
b/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseEndpoint.java
index f788c6bfa659..7b4f3230a561 100644
--- 
a/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseEndpoint.java
+++ 
b/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseEndpoint.java
@@ -51,15 +51,16 @@ import org.apache.camel.util.ObjectHelper;
                                                 + " it first. Query takes the 
SQL statement"),
            producerOut = @BodyType(value = Carrier.UNCHANGED,
                                    byOption = {
-                                           @BodyType.When(option = 
"operation", value = "QUERY", carriers = Carrier.TEXT,
-                                                          javaType = 
"java.lang.String"),
+                                           @BodyType.When(option = 
"operation", value = "QUERY",
+                                                          carriers = { 
Carrier.TEXT, Carrier.BYTES }),
                                            @BodyType.When(option = 
"operation", value = "PING", carriers = Carrier.VALUE,
                                                           javaType = 
"java.lang.Boolean") },
                                    description = "Insert leaves the body 
unchanged, with the count in the"
-                                                 + " 
CamelClickHouseWrittenRows header. Query gives the result as a String"
-                                                 + " in the format option 
(JSONEachRow: one JSON object per line); binary"
-                                                 + " formats such as RowBinary 
do not survive the String. Ping gives a"
-                                                 + " Boolean. The 
CamelClickHouseOperation header overrides the operation"))
+                                                 + " 
CamelClickHouseWrittenRows header. Query gives the result in the"
+                                                 + " format option: a String 
for text formats (JSONEachRow: one JSON object"
+                                                 + " per line), a byte[] for 
binary formats such as RowBinary, Native or"
+                                                 + " Parquet. Ping gives a 
Boolean. The CamelClickHouseOperation header"
+                                                 + " overrides the operation"))
 public class ClickHouseEndpoint extends DefaultEndpoint {
 
     private Client client;
diff --git 
a/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseProducer.java
 
b/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseProducer.java
index 823f6a5f7740..1dea7d1c7ac3 100644
--- 
a/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseProducer.java
+++ 
b/components/camel-clickhouse/src/main/java/org/apache/camel/component/clickhouse/ClickHouseProducer.java
@@ -22,6 +22,8 @@ import java.io.FileInputStream;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
 
 import com.clickhouse.client.api.Client;
 import com.clickhouse.client.api.insert.InsertResponse;
@@ -122,24 +124,46 @@ public class ClickHouseProducer extends DefaultProducer {
     }
 
     private long insertList(Client client, String target, List<?> data, 
InsertSettings settings) throws Exception {
+        if (!data.isEmpty() && data.get(0) instanceof Map) {
+            throw new ClickHouseException(
+                    "Cannot insert a List of Maps into " + target + ": a List 
body must hold objects whose class is"
+                                          + " registered on the Client with 
Client.register(Class, TableSchema)."
+                                          + " Marshal the rows to the format 
option (such as JSONEachRow) instead.");
+        }
         int batchSize = endpoint.getBatchSize();
         if (batchSize <= 0 || data.size() <= batchSize) {
-            try (InsertResponse response = client.insert(target, data, 
settings).get()) {
-                return response.getWrittenRows();
-            }
+            return insertBatch(client, target, data, settings);
         }
 
         // split the list into batches of batchSize, inserting each batch 
separately
         long writtenRows = 0;
         for (int start = 0; start < data.size(); start += batchSize) {
             List<?> batch = data.subList(start, Math.min(start + batchSize, 
data.size()));
-            try (InsertResponse response = client.insert(target, batch, 
settings).get()) {
-                writtenRows += response.getWrittenRows();
-            }
+            writtenRows += insertBatch(client, target, batch, settings);
         }
         return writtenRows;
     }
 
+    private long insertBatch(Client client, String target, List<?> batch, 
InsertSettings settings) throws Exception {
+        CompletableFuture<InsertResponse> future;
+        try {
+            future = client.insert(target, batch, settings);
+        } catch (NullPointerException e) {
+            // client-v2 throws a NullPointerException that does not say why 
when no class is registered for the table
+            Object first = batch.isEmpty() ? null : batch.get(0);
+            String what = first != null ? "a List of " + 
first.getClass().getName() : "the List";
+            throw new ClickHouseException(
+                    "Cannot insert " + what + " into " + target
+                                          + ": no class is registered on the 
Client for this table. Register the class"
+                                          + " with Client.register(Class, 
TableSchema), or send the rows as text, bytes,"
+                                          + " a stream or a file in the format 
option.",
+                    e);
+        }
+        try (InsertResponse response = future.get()) {
+            return response.getWrittenRows();
+        }
+    }
+
     private void doQuery(Exchange exchange) throws Exception {
         Client client = endpoint.getClient();
         String sql = exchange.getIn().getBody(String.class);
@@ -147,21 +171,27 @@ public class ClickHouseProducer extends DefaultProducer {
             throw new ClickHouseException("The SQL query must be provided in 
the message body for the query operation");
         }
 
+        ClickHouseFormat format = resolveFormat(exchange);
         QuerySettings settings = new QuerySettings();
-        settings.setFormat(resolveFormat(exchange));
+        settings.setFormat(format);
         String database = resolveDatabase(exchange);
         if (ObjectHelper.isNotEmpty(database)) {
             settings.setDatabase(database);
         }
 
         try (QueryResponse response = client.query(sql, settings).get()) {
-            String result;
+            byte[] result;
             try (InputStream is = response.getInputStream()) {
-                result = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+                result = is.readAllBytes();
             }
             Message message = exchange.getMessage();
             message.setHeader(ClickHouseConstants.READ_ROWS, 
response.getReadRows());
-            message.setBody(result);
+            if (isBinary(format)) {
+                // binary formats such as RowBinary, Native or Parquet are not 
valid UTF-8 text
+                message.setBody(result);
+            } else {
+                message.setBody(new String(result, StandardCharsets.UTF_8));
+            }
         }
     }
 
@@ -188,6 +218,11 @@ public class ClickHouseProducer extends DefaultProducer {
         return ClickHouseFormat.valueOf(format);
     }
 
+    private static boolean isBinary(ClickHouseFormat format) {
+        // NDJSON is JSON text (one object per line), but clickhouse-data 
flags it as binary
+        return format.isBinary() && format != ClickHouseFormat.NDJSON;
+    }
+
     private String resolveDatabase(Exchange exchange) {
         String database = 
exchange.getIn().getHeader(ClickHouseConstants.DATABASE, String.class);
         if (ObjectHelper.isNotEmpty(database)) {
diff --git 
a/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/ClickHouseProducerTest.java
 
b/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/ClickHouseProducerTest.java
index 2b9e08b1e57c..e25747a17896 100644
--- 
a/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/ClickHouseProducerTest.java
+++ 
b/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/ClickHouseProducerTest.java
@@ -31,6 +31,7 @@ import com.clickhouse.client.api.query.QueryResponse;
 import com.clickhouse.client.api.query.QuerySettings;
 import com.clickhouse.data.ClickHouseFormat;
 import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
 import org.apache.camel.WrappedFile;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.component.mock.MockEndpoint;
@@ -44,6 +45,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -97,6 +99,12 @@ class ClickHouseProducerTest extends CamelTestSupport {
                 from("direct:query")
                         
.to("clickhouse://analytics?operation=query&format=CSV")
                         .to("mock:result");
+                from("direct:queryRowBinary")
+                        
.to("clickhouse://analytics?operation=query&format=RowBinary")
+                        .to("mock:result");
+                from("direct:queryNdjson")
+                        
.to("clickhouse://analytics?operation=query&format=NDJSON")
+                        .to("mock:result");
                 from("direct:ping")
                         .to("clickhouse://analytics?operation=ping")
                         .to("mock:result");
@@ -138,18 +146,60 @@ class ClickHouseProducerTest extends CamelTestSupport {
 
     @Test
     void insertUsesNativeListApiForListBodies() throws Exception {
-        List<Map<String, Object>> rows = List.of(Map.of("id", 1, "name", 
"alice"), Map.of("id", 2, "name", "bob"));
+        List<Event> rows = List.of(new Event(1, "alice"), new Event(2, "bob"));
 
         template.sendBody("direct:insert", rows);
 
         verify(client).insert(anyString(), anyList(), 
any(InsertSettings.class));
     }
 
+    @Test
+    void insertListOfMapsFailsWithClearMessage() {
+        List<Map<String, Object>> rows = List.of(Map.of("id", 1, "name", 
"alice"));
+
+        Exchange out = template.send("direct:insert", exchange -> 
exchange.getIn().setBody(rows));
+
+        assertThat(out.getException())
+                .isInstanceOf(ClickHouseException.class)
+                .hasMessageContaining("List of Maps")
+                .hasMessageContaining("JSONEachRow");
+        verify(client, never()).insert(anyString(), anyList(), 
any(InsertSettings.class));
+    }
+
+    @Test
+    void insertListWithoutRegisteredClassFailsWithClearMessage() {
+        // client-v2 throws a NullPointerException when no class was 
registered for the table
+        when(client.insert(anyString(), anyList(), 
any(InsertSettings.class))).thenThrow(new NullPointerException());
+
+        Exchange out = template.send("direct:insert",
+                exchange -> exchange.getIn().setBody(List.of(new Event(1, 
"alice"))));
+
+        assertThat(out.getException())
+                .isInstanceOf(ClickHouseException.class)
+                .hasMessageContaining(Event.class.getName())
+                .hasMessageContaining("Client.register")
+                .hasCauseInstanceOf(NullPointerException.class);
+    }
+
+    @Test
+    void insertEmptyListKeepsTheClientErrorAsCause() {
+        // the message must not fail on the empty batch and hide the 
NullPointerException of the client
+        when(client.insert(anyString(), anyList(), 
any(InsertSettings.class))).thenThrow(new NullPointerException());
+
+        Exchange out = template.send("direct:insert", exchange -> 
exchange.getIn().setBody(List.of()));
+
+        assertThat(out.getException())
+                .isInstanceOf(ClickHouseException.class)
+                .hasMessageContaining("Cannot insert the List")
+                .hasMessageContaining("Client.register")
+                .hasCauseInstanceOf(NullPointerException.class);
+    }
+
     @Test
     void insertSplitsListIntoBatchesWhenBatchSizeSet() throws Exception {
         // batchSize=2 with 5 rows -> 3 insert calls (2 + 2 + 1)
-        List<Map<String, Object>> rows = List.of(
-                Map.of("id", 1), Map.of("id", 2), Map.of("id", 3), 
Map.of("id", 4), Map.of("id", 5));
+        List<Event> rows = List.of(
+                new Event(1, "a"), new Event(2, "b"), new Event(3, "c"), new 
Event(4, "d"), new Event(5, "e"));
 
         template.sendBody("direct:insertBatched", rows);
 
@@ -206,6 +256,32 @@ class ClickHouseProducerTest extends CamelTestSupport {
         assertThat(sqlCaptor.getValue()).isEqualTo("SELECT * FROM 
analytics.events");
     }
 
+    @Test
+    void queryWithBinaryFormatReturnsBytes() throws Exception {
+        // RowBinary of (UInt32 200, String 'é'): bytes that are not valid 
UTF-8 on their own
+        byte[] rowBinary = { (byte) 0xC8, 0x00, 0x00, 0x00, 0x02, (byte) 0xC3, 
(byte) 0xA9 };
+        when(queryResponse.getInputStream()).thenReturn(new 
ByteArrayInputStream(rowBinary));
+
+        Object result = template.requestBody("direct:queryRowBinary", "SELECT 
id, name FROM analytics.events");
+
+        assertThat(result).isInstanceOf(byte[].class);
+        assertThat((byte[]) result).containsExactly(rowBinary);
+
+        ArgumentCaptor<QuerySettings> settingsCaptor = 
ArgumentCaptor.forClass(QuerySettings.class);
+        verify(client).query(anyString(), settingsCaptor.capture());
+        
assertThat(settingsCaptor.getValue().getFormat()).isEqualTo(ClickHouseFormat.RowBinary);
+    }
+
+    @Test
+    void queryWithNdjsonFormatReturnsString() throws Exception {
+        when(queryResponse.getInputStream())
+                .thenReturn(new 
ByteArrayInputStream("{\"id\":1,\"name\":\"alice\"}\n".getBytes(StandardCharsets.UTF_8)));
+
+        Object result = template.requestBody("direct:queryNdjson", "SELECT id, 
name FROM analytics.events");
+
+        assertThat(result).isEqualTo("{\"id\":1,\"name\":\"alice\"}\n");
+    }
+
     @Test
     void pingReturnsBooleanBodyAndHeader() throws Exception {
         Object result = template.requestBody("direct:ping", (Object) null);
@@ -249,4 +325,7 @@ class ClickHouseProducerTest extends CamelTestSupport {
                 .isInstanceOf(ClickHouseException.class)
                 .hasMessageContaining("SQL query");
     }
+
+    public record Event(int id, String name) {
+    }
 }
diff --git 
a/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/integration/ClickHouseProducerIT.java
 
b/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/integration/ClickHouseProducerIT.java
index 03a141356a96..68bb594b8b47 100644
--- 
a/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/integration/ClickHouseProducerIT.java
+++ 
b/components/camel-clickhouse/src/test/java/org/apache/camel/component/clickhouse/integration/ClickHouseProducerIT.java
@@ -16,11 +16,16 @@
  */
 package org.apache.camel.component.clickhouse.integration;
 
+import java.util.List;
+import java.util.Map;
+
 import com.clickhouse.client.api.Client;
 import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.component.clickhouse.ClickHouseComponent;
 import org.apache.camel.component.clickhouse.ClickHouseConstants;
+import org.apache.camel.component.clickhouse.ClickHouseException;
 import org.apache.camel.component.mock.MockEndpoint;
 import org.apache.camel.test.infra.clickhouse.services.ClickHouseService;
 import 
org.apache.camel.test.infra.clickhouse.services.ClickHouseServiceFactory;
@@ -83,6 +88,9 @@ class ClickHouseProducerIT extends CamelTestSupport {
                 from("direct:query")
                         .to("clickhouse://" + service.getDatabaseName() + 
"?operation=query&format=CSV")
                         .to("mock:result");
+                from("direct:queryRowBinary")
+                        .to("clickhouse://" + service.getDatabaseName() + 
"?operation=query&format=RowBinary")
+                        .to("mock:result");
                 from("direct:ping")
                         .to("clickhouse://" + service.getDatabaseName() + 
"?operation=ping")
                         .to("mock:result");
@@ -108,9 +116,43 @@ class ClickHouseProducerIT extends CamelTestSupport {
         assertThat(result.trim()).isEqualTo("2");
     }
 
+    @Test
+    void queryWithBinaryFormatReturnsBytes() {
+        template.sendBody("direct:insert", 
"{\"id\":200,\"name\":\"\u00e9\"}\n");
+
+        Object result = template.requestBody("direct:queryRowBinary",
+                "SELECT id, name FROM " + service.getDatabaseName() + 
".camel_events");
+
+        // RowBinary: UInt32 200 little endian, then the String as its length 
and its UTF-8 bytes
+        assertThat(result).isInstanceOf(byte[].class);
+        assertThat((byte[]) result).containsExactly((byte) 0xC8, 0x00, 0x00, 
0x00, 0x02, (byte) 0xC3, (byte) 0xA9);
+    }
+
+    @Test
+    void insertListOfMapsFailsWithClearMessage() {
+        Exchange out = template.send("direct:insert",
+                exchange -> exchange.getIn().setBody(List.of(Map.of("id", 1, 
"name", "alice"))));
+
+        
assertThat(out.getException()).isInstanceOf(ClickHouseException.class).hasMessageContaining("List
 of Maps");
+    }
+
+    @Test
+    void insertListWithoutRegisteredClassFailsWithClearMessage() {
+        Exchange out = template.send("direct:insert",
+                exchange -> exchange.getIn().setBody(List.of(new Event(1, 
"alice"))));
+
+        assertThat(out.getException())
+                .isInstanceOf(ClickHouseException.class)
+                .hasMessageContaining(Event.class.getName())
+                .hasMessageContaining("Client.register");
+    }
+
     @Test
     void pingReturnsTrue() {
         Boolean result = template.requestBody("direct:ping", null, 
Boolean.class);
         assertThat(result).isTrue();
     }
+
+    public record Event(int id, String name) {
+    }
 }
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 e6138b4d018b..31e006dc2fbf 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
@@ -653,6 +653,17 @@ message sent to that endpoint returned only the first 
page, and after one messag
 `CamelOaimphIgnoreSSLWarnings=true` every later message accepted a self-signed 
certificate. A message without the
 header uses the `onlyFirst` and `ignoreSSLWarnings` options of the endpoint.
 
+=== camel-clickhouse - a query in a binary format returns a byte[] body
+
+A `query` with a binary `format` (such as `RowBinary`, `Native`, `Parquet`, 
`Arrow` or `Avro`) now returns the result
+as a `byte[]` body. Before, the bytes were decoded as UTF-8 text into a 
`String`, which replaced the bytes that are not
+valid UTF-8, so the result could not be read. A query with a text format (such 
as `JSONEachRow`, `CSV` or `TSV`) still
+returns a `String`.
+
+Inserting a `List` of `Map` now fails with a `ClickHouseException` that says 
why, and so does a `List` of objects when no
+class is registered on the `Client` for the table. Before, both failed with a 
`NullPointerException` from the ClickHouse
+client.
+
 === camel-cm-sms - the from of an SMSMessage is the sender of the SMS
 
 An `SMSMessage` with a `from` is now sent with that sender. Before, the sender 
was set only when `from` was empty (to
diff --git 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/ClickHouseEndpointBuilderFactory.java
 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/ClickHouseEndpointBuilderFactory.java
index 43d70e9e4b28..edb2fcf14583 100644
--- 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/ClickHouseEndpointBuilderFactory.java
+++ 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/ClickHouseEndpointBuilderFactory.java
@@ -511,7 +511,9 @@ public interface ClickHouseEndpointBuilderFactory {
             return "CamelClickHouseWrittenRows";
         }
         /**
-         * The number of rows read by a query operation.
+         * The number of rows the server read (scanned) to run a query
+         * operation. This is not the number of rows in the result: with a 
WHERE
+         * or a LIMIT it is usually more.
          * 
          * The option is a: {@code long} type.
          * 

Reply via email to