diff --git a/CHANGELOG.md b/CHANGELOG.md index 320805a55..37734476b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ - **[client-v2]** Fixed inconsistent use of `executionTimeout` parameter in `Client` component. The timeout was previously set in milliseconds but mistakenly retrieved and used in seconds in some places. Now it correctly uses milliseconds consistently. (https://github.com/ClickHouse/clickhouse-java/issues/2358) +- **[client-v2]** Fixed container query parameters being sent unquoted, so `Client.query(sql, params, settings)` binding a `List` (or an array/`Map`) to a placeholder like `{ids:Array(Date)}` was rejected by the server with `CANNOT_PARSE_INPUT_ASSERTION_FAILED`. Parameter values are now formatted by `DataTypeConverter#convertParameterToString(Object)` before being sent: pass the raw Java value and the client renders it into the text the server's `param_` interface expects — a `Collection`, array (object or primitive), or `Map` becomes ClickHouse `Array` (`['2026-05-13']`) / `Map` (`{'k':'v'}`) text with `String`/temporal leaves single-quoted and numeric/boolean leaves left unquoted, while a scalar is passed through unquoted as before. No manual pre-formatting of container parameters is needed. (https://github.com/ClickHouse/clickhouse-java/issues/2897) + ## 0.9.8 ### Improvements 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..d7f4a0f34 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,12 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; +import java.util.ArrayDeque; +import java.util.Collection; import java.util.Date; +import java.util.Deque; import java.util.List; +import java.util.Map; /** * Class designed to convert different data types to Java objects. @@ -221,6 +226,127 @@ 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()); + } + + /** + * Renders a {@code Collection}/array/{@code Map} parameter value into ClickHouse + * {@code Array} ({@code [e1,e2,...]}) or {@code Map} ({@code {k:v,...}}) text using an explicit + * work stack rather than recursion, so that arbitrarily deep nesting cannot overflow the call + * stack. Work items are pushed in reverse so they pop in left-to-right output order: a container + * expands into its punctuation and child values, while a leaf is formatted with + * {@link ClickHouseValues#convertToSqlExpression(Object)} (String/temporal single-quoted, + * numeric/boolean unquoted, {@code null} -> {@code NULL}) - exactly what the server's array/map + * text parser expects for nested elements. + */ + private String convertParameterContainer(Object value) { + StringBuilder sb = new StringBuilder(); + Deque stack = new ArrayDeque<>(); + stack.push(value); + while (!stack.isEmpty()) { + Object item = stack.pop(); + if (item instanceof Literal) { + sb.append(((Literal) item).text); + } else if (isParameterContainer(item)) { + pushContainer(stack, item); + } else { + sb.append(ClickHouseValues.convertToSqlExpression(item)); + } + } + return sb.toString(); + } + + /** Expands one container onto the work {@code stack}, pushed in reverse so it pops in output order. */ + private void pushContainer(Deque stack, Object container) { + if (container instanceof Map) { + stack.push(MAP_CLOSE); + Object[] entries = ((Map) container).entrySet().toArray(); + for (int i = entries.length - 1; i >= 0; i--) { + Map.Entry entry = (Map.Entry) entries[i]; + pushValue(stack, entry.getValue()); + stack.push(COLON); + pushValue(stack, entry.getKey()); + if (i > 0) { + stack.push(COMMA); + } + } + stack.push(MAP_OPEN); + } else if (container instanceof Collection) { + stack.push(ARRAY_CLOSE); + Object[] elements = ((Collection) container).toArray(); + for (int i = elements.length - 1; i >= 0; i--) { + pushValue(stack, elements[i]); + if (i > 0) { + stack.push(COMMA); + } + } + stack.push(ARRAY_OPEN); + } else { + // Reflection handles both Object[] and primitive arrays (int[], long[], ...); + // Array.get autoboxes primitive elements so they render as unquoted numbers/booleans. + stack.push(ARRAY_CLOSE); + for (int i = Array.getLength(container) - 1; i >= 0; i--) { + pushValue(stack, Array.get(container, i)); + if (i > 0) { + stack.push(COMMA); + } + } + stack.push(ARRAY_OPEN); + } + } + + /** + * Pushes a child value onto the work {@code stack}, substituting {@link #NULL_LITERAL} for a + * {@code null} element because {@link ArrayDeque} does not permit {@code null} entries. + */ + private static void pushValue(Deque stack, Object value) { + stack.push(value == null ? NULL_LITERAL : value); + } + + /** A pre-rendered literal on the work stack: structural punctuation, or the {@code null} leaf. */ + private static final class Literal { + final String text; + + Literal(String text) { + this.text = text; + } + } + + private static final Literal ARRAY_OPEN = new Literal("["); + private static final Literal ARRAY_CLOSE = new Literal("]"); + private static final Literal MAP_OPEN = new Literal("{"); + private static final Literal MAP_CLOSE = new Literal("}"); + private static final Literal COMMA = new Literal(","); + private static final Literal COLON = new Literal(":"); + private static final Literal NULL_LITERAL = new Literal(ClickHouseValues.convertToSqlExpression(null)); + 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..ce2573581 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,79 @@ 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); + } + + @Test + public void testConvertDeeplyNestedParameterDoesNotOverflow() { + // convertParameterContainer uses an explicit work stack (not recursion), so a deeply nested + // container must format correctly without a StackOverflowError. Recursion overflowed here. + int depth = 20_000; + Object nested = LocalDate.of(2026, 5, 13); + for (int i = 0; i < depth; i++) { + nested = Collections.singletonList(nested); + } + + StringBuilder expected = new StringBuilder(depth * 2 + 12); + for (int i = 0; i < depth; i++) { + expected.append('['); + } + expected.append("'2026-05-13'"); + for (int i = 0; i < depth; i++) { + expected.append(']'); + } + + assertEquals(new DataTypeConverter().convertParameterToString(nested), expected.toString()); + } } \ 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..6b389f019 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,37 @@ public void testQueryParamsWithArrays() { Assert.assertEquals(records.get(1).getString("name"), "ENGINES"); } + @DataProvider(name = "containerQueryParameters") + Object[][] containerQueryParameters() { + // {clickHouseType, value, expected} - add a row to extend coverage to another container/type. + return new Object[][]{ + // String and temporal elements must be single-quoted so the server's param parser accepts them. + {"Array(Date)", Arrays.asList(LocalDate.of(2026, 5, 13), LocalDate.of(2026, 5, 14)), "['2026-05-13','2026-05-14']"}, + {"Array(DateTime)", Arrays.asList(LocalDateTime.of(2026, 5, 13, 1, 2, 3)), "['2026-05-13 01:02:03']"}, + {"Array(String)", Arrays.asList("a", "b"), "['a','b']"}, + {"Map(String, Date)", Collections.singletonMap("k", LocalDate.of(2026, 5, 13)), "{'k':'2026-05-13'}"}, + // Contrast: numeric elements must stay unquoted (quoting an Array(Int*/Float*) triggers CANNOT_READ_ARRAY_FROM_TEXT). + {"Array(Int32)", Arrays.asList(1, 2, 3), "[1,2,3]"}, + {"Array(Float64)", Arrays.asList(1.5, 2.5), "[1.5,2.5]"}, + // Object arrays, primitive arrays and nested containers must round-trip too, not just List. + {"Array(Date)", new LocalDate[]{LocalDate.of(2026, 5, 13)}, "['2026-05-13']"}, + {"Array(Int32)", new int[]{4, 5, 6}, "[4,5,6]"}, + {"Array(Array(Int32))", Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3, 4)), "[[1,2],[3,4]]"}, + }; + } + + @Test(groups = {"integration"}, dataProvider = "containerQueryParameters") + public void testContainerQueryParamsQuoteInnerValues(String clickHouseType, Object value, String expected) { + // Regression: Array/Map parameters whose elements were emitted unquoted (e.g. param_p=[2026-05-13]) + // were rejected by the server with HTTP 400. Raw List/array/Map values must round-trip without the + // manual DataTypeConverter pre-formatting workaround; the query is built from the parameter's type. + Map params = Collections.singletonMap("p", value); + List records = client.queryAll("SELECT toString({p:" + clickHouseType + "}) AS v", params); + + Assert.assertEquals(records.size(), 1); + Assert.assertEquals(records.get(0).getString("v"), expected); + } + @Test(groups = {"integration"}) public void testExecuteQueryParam() throws ExecutionException, InterruptedException, TimeoutException {