diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 5cf21a52e..3bf94c529 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -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; @@ -1715,7 +1716,15 @@ public CompletableFuture query(String sqlQuery, Map parser expects + // (e.g. {dates:Array(Date)} <- List becomes ['2026-05-13'], not [2026-05-13]). + Map formattedParams = new LinkedHashMap<>(); + for (Map.Entry 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) { diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java index b1f6a8520..259749181 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java @@ -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; @@ -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. @@ -221,6 +224,88 @@ public String arrayToString(Object value, String columnDef) { return arrayToString(value, column); } + /** + * Converts a query-parameter value into the text form expected by ClickHouse's HTTP + * {@code param_} 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. + * + *

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.

+ * + * @param value parameter value, may be {@code null} + * @return the formatted {@code param_} 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()); + } + + private String convertParameterContainer(Object value) { + 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[], ...); + // 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(); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java index 59699addf..d3c316670 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/DataTypeConverterTest.java @@ -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; @@ -164,4 +166,57 @@ public void testVariantOrDynamicGeoToString() { 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"}, + {"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']"}, + {Arrays.asList("a", "b"), "['a','b']"}, + {Collections.singletonList(LocalDateTime.of(2026, 5, 13, 16, 10, 0)), "['2026-05-13 16:10:00']"}, + {new LocalDate[] {LocalDate.of(2026, 5, 13)}, "['2026-05-13']"}, + + // --- 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). --- + {Collections.singletonList(Collections.singletonList(LocalDate.of(2026, 5, 13))), "[['2026-05-13']]"}, + {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'}"}, + + // --- Escaping and null leaves. --- + {Collections.singletonList("a'b"), "['a\\'b']"}, + {Arrays.asList(LocalDate.of(2026, 5, 13), null), "['2026-05-13',NULL]"}, + + // --- 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); + } } \ No newline at end of file diff --git a/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java b/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java index d9db0d0ce..bcbb970b7 100644 --- a/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/query/QueryTests.java @@ -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; @@ -1660,6 +1661,45 @@ public void testQueryParamsWithArrays() { 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 params = new HashMap<>(); + params.put("dates", Arrays.asList(LocalDate.of(2026, 5, 13), LocalDate.of(2026, 5, 14))); + 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))); + // 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)}); + params.put("intArr", new int[]{4, 5, 6}); + params.put("nested", Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3, 4))); + + List 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); + // 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 {