Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.clickhouse.client.api.insert.InsertSettings;
import com.clickhouse.client.api.internal.ClientStatisticsHolder;
import com.clickhouse.client.api.internal.CredentialsManager;
import com.clickhouse.client.api.internal.DataTypeConverter;
import com.clickhouse.client.api.internal.HttpAPIClientHelper;
import com.clickhouse.client.api.internal.MapUtils;
import com.clickhouse.client.api.internal.TableSchemaParser;
Expand Down Expand Up @@ -1715,7 +1716,15 @@ public CompletableFuture<QueryResponse> query(String sqlQuery, Map<String, Objec
clientStats.start(ClientMetrics.OP_DURATION);

if (queryParams != null) {
requestSettings.setOption(HttpAPIClientHelper.KEY_STATEMENT_PARAMS, queryParams);
// Format parameter values here so the transport layer receives ready-to-send text:
// Array/Map values are quoted the way the server's param_<name> parser expects
// (e.g. {dates:Array(Date)} <- List<LocalDate> becomes ['2026-05-13'], not [2026-05-13]).
Map<String, String> formattedParams = new LinkedHashMap<>();
for (Map.Entry<String, Object> param : queryParams.entrySet()) {
formattedParams.put(param.getKey(),
DataTypeConverter.INSTANCE.convertParameterToString(param.getValue()));
}
requestSettings.setOption(HttpAPIClientHelper.KEY_STATEMENT_PARAMS, formattedParams);
}

if (requestSettings.getQueryId() == null && queryIdGenerator != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.clickhouse.client.api.data_formats.internal.BinaryStreamReader;
import com.clickhouse.data.ClickHouseColumn;
import com.clickhouse.data.ClickHouseDataType;
import com.clickhouse.data.ClickHouseValues;

import java.io.IOException;
import java.lang.reflect.Array;
Expand All @@ -17,8 +18,10 @@
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;

/**
* Class designed to convert different data types to Java objects.
Expand Down Expand Up @@ -221,6 +224,88 @@
return arrayToString(value, column);
}

/**
* Converts a query-parameter value into the text form expected by ClickHouse's HTTP
* {@code param_<name>} interface, allowing callers to pass raw Java values - including
* {@link Collection}, array (object or primitive) and {@link Map} values - for
* {@code Array}/{@code Map} placeholders without pre-formatting them.
*
* <p>A top-level scalar is returned in its bare, unquoted text form, which is what the server
* expects for a scalar {@code {name:Type}} placeholder (e.g. a {@code Date} is sent as
* {@code 2026-05-13}, not {@code '2026-05-13'}). A container is rendered as a ClickHouse
* {@code Array} ({@code [..]}) or {@code Map} ({@code {..}}) text literal in which
* {@code String}/temporal leaves are single-quoted (and escaped) while numeric/boolean leaves
* are left unquoted, as required by the server's array/map text parser.</p>
*
* @param value parameter value, may be {@code null}
* @return the formatted {@code param_<name>} value
*/
public String convertParameterToString(Object value) {
if (isParameterContainer(value)) {
return convertParameterContainer(value);
}
// Scalars (and null) are passed through unquoted: the server reads a scalar parameter value
// verbatim, so quoting it here would break parsing (e.g. Date, numbers, Identifier).
return String.valueOf(value);
}

private boolean isParameterContainer(Object value) {
return value instanceof Collection || value instanceof Map
|| (value != null && value.getClass().isArray());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level char array mishandled

Medium Severity

convertParameterToString treats every Java array as an Array container via getClass().isArray(), but String.valueOf(char[]) is defined to return the character sequence as plain text, not an array literal. A top-level char[] bound to a scalar placeholder (e.g. {s:String}) is now emitted as numeric codepoints like [104,105] instead of the prior string value, which can break queries that previously worked.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0972134. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — I dug into this and it's not a regression; the [104,105] rendering is consistent with this library's existing char[] convention, so I'm leaving it as-is (happy to revisit if you'd prefer char[] treated as text — see end).

The "previously worked / prior string value" premise doesn't hold. Before this PR, params were emitted by HttpAPIClientHelper.addStatementParams via String.valueOf(v), where v is the Map<String,Object> value — statically typed Object. Overload resolution therefore picks String.valueOf(Object), not the String.valueOf(char[]) overload (that one requires a char[] static type). So a top-level char[] was rendered as char[].toString()[C@<hash> identity junk, never the character sequence:

String.valueOf((Object) new char[]{'h','i'})  =>  [C@5c18298f
String.valueOf(new char[]{'h','i'})           =>  hi      // array overload — never reached at this call site

Post-PR, Client.query pre-formats via DataTypeConverter.convertParameterToString(Object) — same Object static type — so the char[] overload was never in play on either side. There was no working char[]'hi' path to break.

[104,105] matches the library's deliberate char[] convention. ClickHouseValues.convertToString(char[]) (ClickHouseValues.java:630-643) renders a char array as its numeric code points ([104,105]), and convertToSqlExpression maps a Character leaf to its code point (:577-578). The new container formatter delegates leaves to convertToSqlExpression, so it now produces the same [104,105] as the rest of the converter — this makes the param path consistent, not divergent. (Net: for {s:String} both the old [C@hash] and the new [104,105] just store a literal string — neither errors, neither is hi; for an Array(UInt16)-style placeholder the new [104,105] actually parses, where [C@hash] did not.)

This is also outside the reported bug (Array(Date) of boxed temporals) — char[] isn't part of it.

If you'd prefer a char[] parameter be treated as text ('hi') — a deliberate divergence from the existing convertToString(char[]) convention — I'm glad to add that as a small explicit special-case. Leaving this thread open for your call.


private String convertParameterContainer(Object value) {

Check failure on line 257 in client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGM2w7IBCOp2OOUG&open=AZ8FXGM2w7IBCOp2OOUG&pullRequest=2898
StringBuilder sb = new StringBuilder();
if (value instanceof Map) {
sb.append('{');
boolean first = true;
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
if (!first) {
sb.append(',');
}
first = false;
sb.append(convertParameterElement(entry.getKey()))
.append(':')
.append(convertParameterElement(entry.getValue()));
}
sb.append('}');
} else {
// Collection or array (object or primitive) -> ClickHouse Array text: [e1,e2,...]
sb.append('[');
if (value instanceof Collection) {
boolean first = true;
for (Object element : (Collection<?>) value) {
if (!first) {
sb.append(',');
}
first = false;
sb.append(convertParameterElement(element));
}
} else {
// Reflection handles both Object[] and primitive arrays (int[], long[], ...);

Check warning on line 285 in client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This block of commented-out lines of code should be removed.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGM2w7IBCOp2OOUH&open=AZ8FXGM2w7IBCOp2OOUH&pullRequest=2898
// Array.get autoboxes primitive elements so they render as unquoted numbers/booleans.
for (int i = 0, len = Array.getLength(value); i < len; i++) {
if (i > 0) {
sb.append(',');
}
sb.append(convertParameterElement(Array.get(value, i)));
}
}
sb.append(']');
}
return sb.toString();
}

private String convertParameterElement(Object value) {
if (isParameterContainer(value)) {
return convertParameterContainer(value);
}
// Leaf value: the type-aware SQL-expression form (String/temporal single-quoted,
// numeric/boolean unquoted, null -> NULL) is exactly what the server's array/map text
// parser expects for nested elements.
return ClickHouseValues.convertToSqlExpression(value);
}

public String geoToString(Object value, ClickHouseColumn column) {
String geoValue = tryGeoToString(value, column);
return geoValue != null ? geoValue : value.toString();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.clickhouse.client.api.internal;

import com.clickhouse.data.ClickHouseColumn;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
Expand Down Expand Up @@ -164,4 +166,57 @@
converter.convertToString(new double[][] {{1D, 2D, 3D}}, ClickHouseColumn.of("field", "Dynamic")),
"[[1.0, 2.0, 3.0]]");
}

@DataProvider(name = "queryParameters")
public static Object[][] queryParameters() {
return new Object[][] {
// --- Scalars: bare, UNQUOTED text form. The server reads a scalar {name:Type} value
// verbatim, so quoting it (e.g. '2026-05-13' for a Date) is rejected. ---
{LocalDate.of(2026, 5, 13), "2026-05-13"},

Check warning on line 175 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOT9&open=AZ8FXGKzw7IBCOp2OOT9&pullRequest=2898
{"hello", "hello"},
{42, "42"},
{new BigDecimal("1.50"), "1.50"},
{null, "null"},

// --- Array/List with String/temporal leaves: single-quoted so the server's array
// text parser accepts them (previously emitted e.g. [2026-05-13] -> HTTP 400). ---
{Arrays.asList(LocalDate.of(2026, 5, 13), LocalDate.of(2026, 5, 14)), "['2026-05-13','2026-05-14']"},

Check warning on line 183 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOT_&open=AZ8FXGKzw7IBCOp2OOT_&pullRequest=2898

Check warning on line 183 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOT-&open=AZ8FXGKzw7IBCOp2OOT-&pullRequest=2898
{Arrays.asList("a", "b"), "['a','b']"},
{Collections.singletonList(LocalDateTime.of(2026, 5, 13, 16, 10, 0)), "['2026-05-13 16:10:00']"},

Check warning on line 185 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOUA&open=AZ8FXGKzw7IBCOp2OOUA&pullRequest=2898
{new LocalDate[] {LocalDate.of(2026, 5, 13)}, "['2026-05-13']"},

Check warning on line 186 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOUB&open=AZ8FXGKzw7IBCOp2OOUB&pullRequest=2898

// --- Primitive arrays are detected via getClass().isArray() and iterated reflectively
// (Array.get autoboxes), so they are no longer mis-rendered as a scalar "[I@..". ---
{new int[] {1, 2, 3}, "[1,2,3]"},
{new double[] {1.0d, 2.5d}, "[1.0,2.5]"},
{new boolean[] {true, false}, "[true,false]"},
// byte[] has no declared type here, so it is treated as a numeric array (Array(Int8)).
{new byte[] {1, 2, 3}, "[1,2,3]"},

// --- Nested containers, including a nested primitive array (List<int[]>). ---
{Collections.singletonList(Collections.singletonList(LocalDate.of(2026, 5, 13))), "[['2026-05-13']]"},

Check warning on line 197 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOUC&open=AZ8FXGKzw7IBCOp2OOUC&pullRequest=2898
{Collections.singletonList(new int[] {1, 2}), "[[1,2]]"},

// --- Map: {k:v} (no spaces), keys/values formatted like array leaves. ---
{Collections.singletonMap("k", LocalDate.of(2026, 5, 13)), "{'k':'2026-05-13'}"},

Check warning on line 201 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOUD&open=AZ8FXGKzw7IBCOp2OOUD&pullRequest=2898

// --- Escaping and null leaves. ---
{Collections.singletonList("a'b"), "['a\\'b']"},
{Arrays.asList(LocalDate.of(2026, 5, 13), null), "['2026-05-13',NULL]"},

Check warning on line 205 in client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGKzw7IBCOp2OOUE&open=AZ8FXGKzw7IBCOp2OOUE&pullRequest=2898

// --- Contrast: numeric containers must stay UNQUOTED (quoting an Array(Int32)/
// Array(Decimal) element causes the server to reject it with CANNOT_READ_ARRAY_FROM_TEXT). ---
{Arrays.asList(1, 2, 3), "[1,2,3]"},
{Collections.singletonList(new BigDecimal("1.50")), "[1.50]"},

// --- Boundary: empty containers. ---
{Collections.emptyList(), "[]"},
{Collections.emptyMap(), "{}"},
};
}

@Test(dataProvider = "queryParameters")
public void testConvertParameterToString(Object value, String expected) {
assertEquals(new DataTypeConverter().convertParameterToString(value), expected);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
Expand Down Expand Up @@ -1660,6 +1661,45 @@
Assert.assertEquals(records.get(1).getString("name"), "ENGINES");
}

@Test(groups = {"integration"})
public void testContainerQueryParamsQuoteInnerValues() {
// Regression for Array/Map parameters whose elements were emitted unquoted (e.g.
// param_dates=[2026-05-13]) and rejected by the server with HTTP 400. Raw List/array/Map
// values must now round-trip without the manual DataTypeConverter pre-formatting workaround.
final Map<String, Object> params = new HashMap<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are many other containers.
What about nested types?

  • tuples can be passed as arrays or lists
  • simple java array should be tested

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended the integration test with an object array (LocalDate[]), a primitive array (int[]) and a nested array (Array(Array(Int32))), alongside the existing List/Map cases.

On Tuple specifically: a raw List/array can't be auto-formatted as a Tuple at this layer. The same runtime List is also how an Array is bound — the reported bug binds {l:Array(Date)} from a List<LocalDate> — and there's no declared-type information here to disambiguate the two. I verified against the server that the formats are mutually exclusive:

param_t=('a',1)  -> OK
param_t=['a',1]  -> Code: 27 ... value ['a',1] cannot be parsed as Tuple(String,Int64) (CANNOT_PARSE_INPUT_ASSERTION_FAILED)

So rendering a List as [...] (required for Array, and for the reported bug) is incompatible with (...) (required for Tuple) unless we parse the {name:Type} placeholder out of the SQL. Tuple was equally unsupported before this PR, so I scoped it out here rather than mis-render it. Happy to follow up with declared-type-aware parameter binding as a separate change if you'd like that. 0972134.

params.put("dates", Arrays.asList(LocalDate.of(2026, 5, 13), LocalDate.of(2026, 5, 14)));

Check warning on line 1670 in client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8E20qMI9QKIFBcMBx3&open=AZ8E20qMI9QKIFBcMBx3&pullRequest=2898

Check warning on line 1670 in client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8E20qMI9QKIFBcMBx2&open=AZ8E20qMI9QKIFBcMBx2&pullRequest=2898
params.put("names", Arrays.asList("a", "b"));
params.put("ints", Arrays.asList(1, 2, 3));
params.put("dateMap", Collections.singletonMap("k", LocalDate.of(2026, 5, 13)));

Check warning on line 1673 in client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8E20qMI9QKIFBcMBx4&open=AZ8E20qMI9QKIFBcMBx4&pullRequest=2898
// Object array, primitive array and a nested array must all be supported, not just List.
params.put("dateArr", new LocalDate[]{LocalDate.of(2026, 5, 13)});

Check warning on line 1675 in client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8FXGMhw7IBCOp2OOUF&open=AZ8FXGMhw7IBCOp2OOUF&pullRequest=2898
params.put("intArr", new int[]{4, 5, 6});
params.put("nested", Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3, 4)));

List<GenericRecord> records = client.queryAll(
"SELECT toString({dates:Array(Date)}) AS d, " +
"toString({names:Array(String)}) AS n, " +
"toString({ints:Array(Int32)}) AS i, " +
"toString({dateMap:Map(String, Date)}) AS m, " +
"toString({dateArr:Array(Date)}) AS da, " +
"toString({intArr:Array(Int32)}) AS ia, " +
"toString({nested:Array(Array(Int32))}) AS ne",
params);

Assert.assertEquals(records.size(), 1);
GenericRecord record = records.get(0);

Check warning on line 1690 in client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this variable to not match a restricted identifier.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ8E20qMI9QKIFBcMBx5&open=AZ8E20qMI9QKIFBcMBx5&pullRequest=2898
// Array(Date)/Array(String) elements are single-quoted so the server parses them.
Assert.assertEquals(record.getString("d"), "['2026-05-13','2026-05-14']");
Assert.assertEquals(record.getString("n"), "['a','b']");
// Contrast: numeric arrays must stay unquoted (quoting causes CANNOT_READ_ARRAY_FROM_TEXT).
Assert.assertEquals(record.getString("i"), "[1,2,3]");
Assert.assertEquals(record.getString("m"), "{'k':'2026-05-13'}");
// Object array, primitive array and nested array round-trip too.
Assert.assertEquals(record.getString("da"), "['2026-05-13']");
Assert.assertEquals(record.getString("ia"), "[4,5,6]");
Assert.assertEquals(record.getString("ne"), "[[1,2],[3,4]]");
}

@Test(groups = {"integration"})
public void testExecuteQueryParam() throws ExecutionException, InterruptedException, TimeoutException {

Expand Down
Loading